feat(workspace): member base permission floor + permission endpoints - #75
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 featurefix— bug fixrefactor— code change without behavior changetest— tests onlydocs— documentation onlybuild/ci— build, dependencies, or CI/CDchore— maintenanceChecklist
develop(notmain)feature/*,bugfix/*, orhotfix/*./gradlew buildpasses locally (compile + tests +verifyModularity) — unit, integration (single-worker), architecture and modularity all green.pemkeys are committedCHANGELOG.mdupdated under[Unreleased]common/ortenant/)What's included
memberBasePermission:NONE|READ, defaultREAD) applied to all project members on top of their project role; owner/admin bypass. Wired throughProjectPermissionService.hasPermissionso every@authz.projectPermission/WorkspaceModuleApi.callerHasProjectPermissiongate honors it. MigrationV20260710090000__organization_member_base_permission.sql(public schema).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).canAccessProject/accessibleProjectIdsgrant all-project access when the floor is non-NONE, else fall back to explicit assignments (previously aREAD-floor member could load a project's stories but was 403'd on the project itself).roleName— so aMEMBER_READcaller sees each member's role without needingROLE_READ.@authz.projectAnyPermission; listing roles now acceptsROLE_READorMEMBER_UPDATE_ROLE/MEMBER_INVITE(the member role editor needs the options).How to Test
./gradlew build(orunitTest,integrationTest --max-workers=1,architectureTest,verifyModularity).PUT /organizations/{orgId}/base-permission {"basePermission":"NONE"}; create a project role granting onlyMEMBER_READ; assign it to a member.GET /projects/{projectId}/me/permissionsreturns["MEMBER_READ", ...];GET .../projects/{projectId}/membersreturns200with eachroleNameembedded; the project itself is accessible.MEMBER_UPDATE_ROLE(noROLE_READ) →GET .../projects/{projectId}/rolesnow returns200(was403).READ→ members regain the read-only baseline across workspace/discovery reads immediately (it's a live org setting, not snapshotted).Notes
develop(onlyCHANGELOG.mdconflicted — resolved keeping both sides).feature/rbac-base-permission) that makes the UI permission-aware.