Skip to content

feat(workspace): member base permission floor + permission endpoints - #75

Merged
jhosepmyr merged 13 commits into
developfrom
feature/rbac-base-permission
Jul 10, 2026
Merged

feat(workspace): member base permission floor + permission endpoints#75
jhosepmyr merged 13 commits into
developfrom
feature/rbac-base-permission

Conversation

@jhosepmyr

Copy link
Copy Markdown
Contributor

Description

Adds a GitHub-style member base permission floor to organizations and the endpoints the frontend needs to be permission-aware, plus a few follow-up refinements found while wiring the UI: the members list embeds each role's name, project access honors the base floor, and listing roles is readable by member-managers.

Bounded context / area: workspace (authz)

Related issue / US:


Type of Change

  • feat — new feature
  • fix — bug fix
  • refactor — code change without behavior change
  • test — tests only
  • docs — documentation only
  • build / ci — build, dependencies, or CI/CD
  • chore — maintenance

Checklist

  • The PR targets develop (not main)
  • Branch name follows feature/*, bugfix/*, or hotfix/*
  • Commits follow Conventional Commits
  • ./gradlew build passes locally (compile + tests + verifyModularity) — unit, integration (single-worker), architecture and modularity all green
  • New cross-module access respects module boundaries (no reaching into another module's internals)
  • Added/updated tests for the change (Testcontainers for DB-dependent code)
  • No secrets, credentials, or .pem keys are committed
  • CHANGELOG.md updated under [Unreleased]
  • Database changes are expressed as Flyway migrations (common/ or tenant/)

What's included

  • Member base permission floor (memberBasePermission: NONE | READ, default READ) applied to all project members on top of their project role; owner/admin bypass. Wired through ProjectPermissionService.hasPermission so every @authz.projectPermission / WorkspaceModuleApi.callerHasProjectPermission gate honors it. Migration V20260710090000__organization_member_base_permission.sql (public schema).
  • Endpoints (Api-Version: 1): GET/PUT /organizations/{orgId}/base-permission (owner/admin), GET /organizations/{orgId}/me/authorization (any member), GET /projects/{projectId}/me/permissions — the caller's effective project permissions (tenant-membership gated).
  • Project access honors the floorcanAccessProject/accessibleProjectIds grant all-project access when the floor is non-NONE, else fall back to explicit assignments (previously a READ-floor member could load a project's stories but was 403'd on the project itself).
  • Members list embeds roleName — so a MEMBER_READ caller sees each member's role without needing ROLE_READ.
  • Roles list readable by member-managers — new @authz.projectAnyPermission; listing roles now accepts ROLE_READ or MEMBER_UPDATE_ROLE/MEMBER_INVITE (the member role editor needs the options).

How to Test

  1. ./gradlew build (or unitTest, integrationTest --max-workers=1, architectureTest, verifyModularity).
  2. As owner: PUT /organizations/{orgId}/base-permission {"basePermission":"NONE"}; create a project role granting only MEMBER_READ; assign it to a member.
  3. As that member: GET /projects/{projectId}/me/permissions returns ["MEMBER_READ", ...]; GET .../projects/{projectId}/members returns 200 with each roleName embedded; the project itself is accessible.
  4. Give the member MEMBER_UPDATE_ROLE (no ROLE_READ) → GET .../projects/{projectId}/roles now returns 200 (was 403).
  5. Flip the floor back to READ → members regain the read-only baseline across workspace/discovery reads immediately (it's a live org setting, not snapshotted).

Notes

  • Rebased onto develop (only CHANGELOG.md conflicted — resolved keeping both sides).
  • Pairs with the frontend PR (feature/rbac-base-permission) that makes the UI permission-aware.

jhosepmyr added 13 commits July 10, 2026 12:38
Introduce a GitHub-style RBAC floor applied to every project member on top of
their explicit project role. A new BasePermission enum (NONE, READ) maps READ to
the workspace *_READ permissions (integration read excluded — integrations are
org-admin config); the Organization aggregate carries memberBasePermission
(default READ) with a domain mutator. A public-schema Flyway migration adds the
member_base_permission column.
hasPermission now grants a permission when the organization's base floor carries
it for an active member, in addition to owner/admin bypass and the project role.
Add effectivePermissions(org, projectId, userId) returning the caller's full
permission set: all permissions for owners/admins, else the union of the base
floor and their project role. This flows through @authz.projectPermission and
WorkspaceModuleApi.callerHasProjectPermission, so every gated endpoint honors it.
…n endpoints

Add three org-scoped endpoints (header Api-Version: 1):
- GET  /organizations/{orgId}/base-permission (owner/admin) -> {basePermission}
- PUT  /organizations/{orgId}/base-permission (owner/admin) sets the floor
- GET  /organizations/{orgId}/me/authorization (member) -> {orgRole, memberBasePermission}

Backed by a CQRS command/handler for the change and query/handlers for the reads;
me/authorization resolves the caller's org role via OrganizationAdminAccessService
(owner -> OWNER, else the member's ADMIN/MEMBER role).
…dpoint

Add GET /api/projects/{projectId}/me/permissions (header Api-Version: 1) ->
{permissions: [...]}, the caller's effective project permissions (base floor
union project role, or the full catalog for owners/admins). The route carries no
orgId, so a new tenant-context projectAccess gate (WorkspaceAuthorization plus
WorkspaceModuleApi.callerCanAccessProject) lets any member who can access the
project — or an org owner/admin — read their own permissions.
… permissions

Unit-test BasePermission.grantedPermissions (NONE empty, READ = the 7 *_READ
baseline), the Organization default (READ) and mutator, and ProjectPermissionService:
base READ grants read-only to a role-less member, base NONE denies even read,
non-members get no floor, owners bypass, and effectivePermissions returns the full
catalog for owners and the base-plus-role union for members. Add a base-permission
override to the Organization test builder.
…oject access

The base-permission floor is organization-wide, so a role-less member legitimately
has read access to a project without an explicit assignment. Gate
GET /projects/{projectId}/me/permissions on active org membership of the bound
tenant (WorkspaceAuthorization.tenantMember + WorkspaceModuleApi.callerIsActiveMember)
so any member can read their own effective set — empty when they have neither a
floor nor a project role — instead of requiring a project assignment.
…endpoints

Testcontainers coverage: PUT base-permission (owner ok, plain member 403), a
role-less member gets 200 on a CONSTRAINT_READ endpoint when base=READ and 403
when base=NONE, me/permissions returns the full catalog for the owner, the READ
floor for a role-less member and floor-plus-role for a member with a custom role,
and me/authorization returns the caller's org role and the floor.
…sion floor

The member base permission floor (default READ) now grants an unassigned org
member implicit read, so this suite's 'unassigned member denied on reads'
assertions no longer held. Pin the test org's floor to NONE so it keeps
verifying project-role gating in isolation; the READ floor is covered by
BasePermissionIntegrationTest.
canAccessProject/accessibleProjectIds required an explicit ProjectMember
assignment for every member, ignoring the organization's base permission
floor. With the default READ floor a member could load a project's stories
and sessions yet be 403'd by @authz.projectAccess on the project itself.

Grant all-project access when the floor is non-NONE; fall back to
assignment-based access only when the floor is NONE. Tests that exercise
assignment-based access now pin the floor to NONE.
Listing project members required only MEMBER_READ, but the frontend also
fetched the project roles (ROLE_READ) just to resolve each member's role
name — so a viewer whose role grants only MEMBER_READ hit a 403 and the
members page failed to load.

Resolve role names in the list query handler and embed roleName in each
ProjectMemberResponse, so the roster is self-contained: MEMBER_READ alone
now shows members with their roles. Single-assignment responses pass null
and the client falls back to its own lookup.
Editing or assigning a member's role needs the roles list for its options,
but listing roles required ROLE_READ — so a member whose role grants
MEMBER_UPDATE_ROLE/MEMBER_INVITE (but not ROLE_READ) got an empty role
editor. Add @authz.projectAnyPermission (holds ANY of several permissions,
resolving the org once) and gate the roles list on ROLE_READ OR
MEMBER_UPDATE_ROLE OR MEMBER_INVITE.
@jhosepmyr
jhosepmyr requested a review from salimramirez as a code owner July 10, 2026 17:49
@jhosepmyr
jhosepmyr merged commit d42be38 into develop Jul 10, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant