From 0557ef7b7d5a7d172065a509a7250fee49664952 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Fri, 10 Jul 2026 08:38:50 -0500 Subject: [PATCH 01/13] feat(workspace): add member base permission floor to the organization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../domain/model/BasePermission.java | 46 +++++++++++++++++++ .../workspace/domain/model/Organization.java | 14 ++++++ ...0__organization_member_base_permission.sql | 7 +++ 3 files changed, 67 insertions(+) create mode 100644 src/main/java/com/kntro/reqsai/workspace/domain/model/BasePermission.java create mode 100644 src/main/resources/db/migration/common/V20260710090000__organization_member_base_permission.sql diff --git a/src/main/java/com/kntro/reqsai/workspace/domain/model/BasePermission.java b/src/main/java/com/kntro/reqsai/workspace/domain/model/BasePermission.java new file mode 100644 index 00000000..5cb68db9 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/workspace/domain/model/BasePermission.java @@ -0,0 +1,46 @@ +package com.kntro.reqsai.workspace.domain.model; + +import java.util.Set; + +/** + * Organization-wide RBAC floor (GitHub "base permissions" model): a baseline applied to every project + * member in addition to whatever their explicit {@link ProjectRole} grants. Project roles are + * additive on top of this floor; organization owners/admins bypass it entirely and keep full access. + * + */ +public enum BasePermission { + + NONE { + @Override + public Set grantedPermissions() { + return Set.of(); + } + }, + + READ { + @Override + public Set grantedPermissions() { + return READ_BASELINE; + } + }; + + /** + * The read-only baseline: the workspace {@code *_READ} permissions members legitimately need as a + * floor. Third-party integrations are organization-admin configuration, so integration read is + * deliberately excluded — members do not get integration visibility from the base floor. + */ + private static final Set READ_BASELINE = Set.of( + Permission.MEMBER_READ, + Permission.ROLE_READ, + Permission.DOCUMENT_READ, + Permission.GLOSSARY_READ, + Permission.CONSTRAINT_READ, + Permission.SESSION_READ, + Permission.STORY_READ); + + /** The permissions this floor grants to every project member of the organization. */ + public abstract Set grantedPermissions(); +} diff --git a/src/main/java/com/kntro/reqsai/workspace/domain/model/Organization.java b/src/main/java/com/kntro/reqsai/workspace/domain/model/Organization.java index e62d121d..c07a1b17 100644 --- a/src/main/java/com/kntro/reqsai/workspace/domain/model/Organization.java +++ b/src/main/java/com/kntro/reqsai/workspace/domain/model/Organization.java @@ -53,6 +53,14 @@ public class Organization extends AggregateRoot { @Column(name = "status", nullable = false, length = 16) private OrgStatus status; + /** + * GitHub-style RBAC floor applied to every project member on top of their explicit project role. + * Defaults to {@link BasePermission#READ}; owners/admins bypass it. + */ + @Enumerated(EnumType.STRING) + @Column(name = "member_base_permission", nullable = false, length = 16) + private BasePermission memberBasePermission; + @Embedded private GenerationSettings settings; @@ -77,6 +85,7 @@ public Organization(String name, Slug slug, UUID ownerId, this.slug = Assert.notNull(slug, "slug"); this.ownerId = Assert.notNull(ownerId, "ownerId"); this.status = OrgStatus.PENDING; + this.memberBasePermission = BasePermission.READ; this.settings = Assert.notNull(settings, "settings"); this.planLimits = Assert.notNull(planLimits, "planLimits"); } @@ -94,6 +103,11 @@ public void updateSettings(GenerationSettings settings) { this.settings = Assert.notNull(settings, "settings"); } + /** Changes the organization-wide RBAC floor applied to every project member. */ + public void changeMemberBasePermission(BasePermission memberBasePermission) { + this.memberBasePermission = Assert.notNull(memberBasePermission, "memberBasePermission"); + } + /** * Applies a partial update: each argument is optional and a {@code null} leaves the corresponding * field unchanged. When present, {@code name} is validated and the settings are rebuilt keeping the diff --git a/src/main/resources/db/migration/common/V20260710090000__organization_member_base_permission.sql b/src/main/resources/db/migration/common/V20260710090000__organization_member_base_permission.sql new file mode 100644 index 00000000..879e14db --- /dev/null +++ b/src/main/resources/db/migration/common/V20260710090000__organization_member_base_permission.sql @@ -0,0 +1,7 @@ +-- GitHub-style RBAC floor: every project member of an organization gets a base permission applied +-- on top of (additive with) their explicit project role. NONE grants nothing extra; READ grants a +-- read-only baseline across the workspace resources. Owners/admins bypass this and keep full access. +-- Lives in the PUBLIC schema alongside the rest of the organizations registry. + +ALTER TABLE public.organizations + ADD COLUMN member_base_permission VARCHAR(16) NOT NULL DEFAULT 'READ'; From a60e7776cf807a52678e8db092d2ec95e6dea189 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Fri, 10 Jul 2026 08:40:10 -0500 Subject: [PATCH 02/13] feat(workspace): apply the member base permission floor in authorization 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. --- .../service/ProjectPermissionService.java | 59 +++++++++++++++---- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/kntro/reqsai/workspace/application/service/ProjectPermissionService.java b/src/main/java/com/kntro/reqsai/workspace/application/service/ProjectPermissionService.java index 10accf6d..e010bf88 100644 --- a/src/main/java/com/kntro/reqsai/workspace/application/service/ProjectPermissionService.java +++ b/src/main/java/com/kntro/reqsai/workspace/application/service/ProjectPermissionService.java @@ -13,15 +13,18 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import java.util.EnumSet; +import java.util.Set; import java.util.UUID; /** * Resolves the caller's effective rights to manage a project's members and roles, per request. *

* Authorization is layered: org {@code OWNER}/{@code ADMIN} always pass (they implicitly manage every - * project). Otherwise the caller must be an active org member assigned to the project, and the - * {@link ProjectRole} backing that assignment must carry the required {@link Permission}. Per-project - * permissions are always queried from the database here — they are never embedded in the JWT. + * project). Otherwise the caller's rights are the union of the organization's base-permission floor + * (a read-only baseline every active member gets, GitHub-style) and whatever the {@link ProjectRole} + * backing their project assignment carries. Per-project permissions are always queried from the + * database here — they are never embedded in the JWT. */ @Component @RequiredArgsConstructor @@ -41,32 +44,68 @@ public void assertHasProjectPermission( /** * Whether the caller may exercise {@code permission} on the project: org owners/admins always may; - * otherwise the caller needs a {@link ProjectMember} assignment whose {@link ProjectRole} carries it. + * otherwise the caller may if the organization's base-permission floor grants it or a + * {@link ProjectMember} assignment's {@link ProjectRole} carries it. */ public boolean hasPermission( Organization organization, UUID projectId, UUID requestedBy, Permission permission) { return orgAccess.isOwnerOrAdmin(organization, requestedBy) + || grantedByBasePermission(organization, requestedBy, permission) || hasProjectPermission(organization, projectId, requestedBy, permission); } + /** + * The caller's effective set of project permissions: all permissions for org owners/admins; + * otherwise the union of the organization's base-permission floor and their project role's grants. + * An empty set when the caller is neither an owner/admin nor an active member of the organization. + */ + public Set effectivePermissions(Organization organization, UUID projectId, UUID requestedBy) { + if (orgAccess.isOwnerOrAdmin(organization, requestedBy)) { + return EnumSet.allOf(Permission.class); + } + Member member = activeMember(organization, requestedBy); + if (member == null) { + return Set.of(); + } + Set effective = EnumSet.noneOf(Permission.class); + effective.addAll(organization.getMemberBasePermission().grantedPermissions()); + effective.addAll(projectRolePermissions(member, projectId)); + return effective; + } + + /** Whether the org's base floor grants {@code permission} to this active member of the org. */ + private boolean grantedByBasePermission( + Organization organization, UUID requestedBy, Permission permission) { + return organization.getMemberBasePermission().grantedPermissions().contains(permission) + && activeMember(organization, requestedBy) != null; + } + private boolean hasProjectPermission( Organization organization, UUID projectId, UUID requestedBy, Permission permission) { - Member member = members.findByOrganizationIdAndUserIdAndStatus( - organization.getId(), requestedBy, MemberStatus.ACTIVE) - .orElse(null); + Member member = activeMember(organization, requestedBy); if (member == null) { return false; } + return projectRolePermissions(member, projectId).contains(permission); + } + + /** The permissions carried by the member's project role for {@code projectId}, empty when unassigned. */ + private Set projectRolePermissions(Member member, UUID projectId) { ProjectMember assignment = assignments.findAllByMemberId(member.getId()).stream() .filter(a -> a.getProjectId().equals(projectId)) .findFirst() .orElse(null); if (assignment == null) { - return false; + return Set.of(); } return roles.findByIdAndProjectId(assignment.getRoleId(), projectId) .map(ProjectRole::getPermissions) - .map(permissions -> permissions.contains(permission)) - .orElse(false); + .orElseGet(Set::of); + } + + private Member activeMember(Organization organization, UUID requestedBy) { + return members.findByOrganizationIdAndUserIdAndStatus( + organization.getId(), requestedBy, MemberStatus.ACTIVE) + .orElse(null); } } From 9530db621e9ab4b140f6c9e7f7a277ec9324c2db Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Fri, 10 Jul 2026 08:43:21 -0500 Subject: [PATCH 03/13] feat(workspace): expose organization base-permission and authorization 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). --- .../ChangeMemberBasePermissionCommand.java | 12 ++ ...ngeMemberBasePermissionCommandHandler.java | 26 +++++ ...OrganizationAuthorizationQueryHandler.java | 32 ++++++ .../GetOrganizationAuthorizationQuery.java | 9 ++ .../result/OrganizationAuthorization.java | 13 +++ ...ganizationAuthorizationControllerImpl.java | 62 ++++++++++ .../request/UpdateBasePermissionRequest.java | 16 +++ .../dto/response/BasePermissionResponse.java | 14 +++ .../OrganizationAuthorizationResponse.java | 21 ++++ ...ganizationAuthorizationResponseMapper.java | 24 ++++ .../OrganizationAuthorizationController.java | 107 ++++++++++++++++++ 11 files changed, 336 insertions(+) create mode 100644 src/main/java/com/kntro/reqsai/workspace/application/command/ChangeMemberBasePermissionCommand.java create mode 100644 src/main/java/com/kntro/reqsai/workspace/application/handler/ChangeMemberBasePermissionCommandHandler.java create mode 100644 src/main/java/com/kntro/reqsai/workspace/application/handler/GetOrganizationAuthorizationQueryHandler.java create mode 100644 src/main/java/com/kntro/reqsai/workspace/application/query/GetOrganizationAuthorizationQuery.java create mode 100644 src/main/java/com/kntro/reqsai/workspace/application/result/OrganizationAuthorization.java create mode 100644 src/main/java/com/kntro/reqsai/workspace/interfaces/rest/controllers/OrganizationAuthorizationControllerImpl.java create mode 100644 src/main/java/com/kntro/reqsai/workspace/interfaces/rest/dto/request/UpdateBasePermissionRequest.java create mode 100644 src/main/java/com/kntro/reqsai/workspace/interfaces/rest/dto/response/BasePermissionResponse.java create mode 100644 src/main/java/com/kntro/reqsai/workspace/interfaces/rest/dto/response/OrganizationAuthorizationResponse.java create mode 100644 src/main/java/com/kntro/reqsai/workspace/interfaces/rest/mappers/response/OrganizationAuthorizationResponseMapper.java create mode 100644 src/main/java/com/kntro/reqsai/workspace/interfaces/rest/swagger/OrganizationAuthorizationController.java diff --git a/src/main/java/com/kntro/reqsai/workspace/application/command/ChangeMemberBasePermissionCommand.java b/src/main/java/com/kntro/reqsai/workspace/application/command/ChangeMemberBasePermissionCommand.java new file mode 100644 index 00000000..038abd06 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/workspace/application/command/ChangeMemberBasePermissionCommand.java @@ -0,0 +1,12 @@ +package com.kntro.reqsai.workspace.application.command; + +import com.kntro.reqsai.workspace.domain.model.BasePermission; + +import java.util.UUID; + +/** Sets the organization-wide RBAC floor applied to every project member. */ +public record ChangeMemberBasePermissionCommand( + UUID organizationId, + BasePermission basePermission, + UUID requestedBy +) {} diff --git a/src/main/java/com/kntro/reqsai/workspace/application/handler/ChangeMemberBasePermissionCommandHandler.java b/src/main/java/com/kntro/reqsai/workspace/application/handler/ChangeMemberBasePermissionCommandHandler.java new file mode 100644 index 00000000..4621f975 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/workspace/application/handler/ChangeMemberBasePermissionCommandHandler.java @@ -0,0 +1,26 @@ +package com.kntro.reqsai.workspace.application.handler; + +import com.kntro.reqsai.workspace.application.command.ChangeMemberBasePermissionCommand; +import com.kntro.reqsai.workspace.application.port.OrganizationRepository; +import com.kntro.reqsai.workspace.domain.exception.WorkspaceExceptions; +import com.kntro.reqsai.workspace.domain.model.Organization; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class ChangeMemberBasePermissionCommandHandler { + + private final OrganizationRepository organizations; + + @Transactional + public Organization handle(ChangeMemberBasePermissionCommand command) { + Organization organization = organizations.findById(command.organizationId()) + .orElseThrow(() -> WorkspaceExceptions.organizationNotFound(command.organizationId())); + + organization.changeMemberBasePermission(command.basePermission()); + + return organizations.save(organization); + } +} diff --git a/src/main/java/com/kntro/reqsai/workspace/application/handler/GetOrganizationAuthorizationQueryHandler.java b/src/main/java/com/kntro/reqsai/workspace/application/handler/GetOrganizationAuthorizationQueryHandler.java new file mode 100644 index 00000000..00e19513 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/workspace/application/handler/GetOrganizationAuthorizationQueryHandler.java @@ -0,0 +1,32 @@ +package com.kntro.reqsai.workspace.application.handler; + +import com.kntro.reqsai.workspace.application.port.OrganizationRepository; +import com.kntro.reqsai.workspace.application.query.GetOrganizationAuthorizationQuery; +import com.kntro.reqsai.workspace.application.result.OrganizationAuthorization; +import com.kntro.reqsai.workspace.application.service.OrganizationAdminAccessService; +import com.kntro.reqsai.workspace.domain.exception.WorkspaceExceptions; +import com.kntro.reqsai.workspace.domain.model.OrgRole; +import com.kntro.reqsai.workspace.domain.model.Organization; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class GetOrganizationAuthorizationQueryHandler { + + private final OrganizationRepository organizations; + private final OrganizationAdminAccessService orgAccess; + + @Transactional(readOnly = true) + public OrganizationAuthorization handle(GetOrganizationAuthorizationQuery query) { + Organization organization = organizations.findById(query.organizationId()) + .orElseThrow(() -> WorkspaceExceptions.organizationNotFound(query.organizationId())); + + OrgRole role = orgAccess.effectiveRole(organization, query.requestedBy()) + .orElseThrow(() -> WorkspaceExceptions.insufficientPermissions( + "read organization authorization", query.requestedBy())); + + return new OrganizationAuthorization(role, organization.getMemberBasePermission()); + } +} diff --git a/src/main/java/com/kntro/reqsai/workspace/application/query/GetOrganizationAuthorizationQuery.java b/src/main/java/com/kntro/reqsai/workspace/application/query/GetOrganizationAuthorizationQuery.java new file mode 100644 index 00000000..29fb1525 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/workspace/application/query/GetOrganizationAuthorizationQuery.java @@ -0,0 +1,9 @@ +package com.kntro.reqsai.workspace.application.query; + +import java.util.UUID; + +/** Resolves the caller's authorization context (org role + base-permission floor) in an organization. */ +public record GetOrganizationAuthorizationQuery( + UUID organizationId, + UUID requestedBy +) {} diff --git a/src/main/java/com/kntro/reqsai/workspace/application/result/OrganizationAuthorization.java b/src/main/java/com/kntro/reqsai/workspace/application/result/OrganizationAuthorization.java new file mode 100644 index 00000000..b42db8b3 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/workspace/application/result/OrganizationAuthorization.java @@ -0,0 +1,13 @@ +package com.kntro.reqsai.workspace.application.result; + +import com.kntro.reqsai.workspace.domain.model.BasePermission; +import com.kntro.reqsai.workspace.domain.model.OrgRole; + +/** + * The caller's authorization context in an organization: their effective org role and the + * organization-wide member base-permission floor. + */ +public record OrganizationAuthorization( + OrgRole orgRole, + BasePermission memberBasePermission +) {} diff --git a/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/controllers/OrganizationAuthorizationControllerImpl.java b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/controllers/OrganizationAuthorizationControllerImpl.java new file mode 100644 index 00000000..2dafeb6f --- /dev/null +++ b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/controllers/OrganizationAuthorizationControllerImpl.java @@ -0,0 +1,62 @@ +package com.kntro.reqsai.workspace.interfaces.rest.controllers; + +import com.kntro.reqsai.workspace.application.command.ChangeMemberBasePermissionCommand; +import com.kntro.reqsai.workspace.application.handler.ChangeMemberBasePermissionCommandHandler; +import com.kntro.reqsai.workspace.application.handler.GetOrganizationAuthorizationQueryHandler; +import com.kntro.reqsai.workspace.application.handler.GetOrganizationQueryHandler; +import com.kntro.reqsai.workspace.application.query.GetOrganizationAuthorizationQuery; +import com.kntro.reqsai.workspace.application.query.GetOrganizationQuery; +import com.kntro.reqsai.workspace.application.result.OrganizationAuthorization; +import com.kntro.reqsai.workspace.domain.model.Organization; +import com.kntro.reqsai.workspace.interfaces.rest.dto.request.UpdateBasePermissionRequest; +import com.kntro.reqsai.workspace.interfaces.rest.dto.response.BasePermissionResponse; +import com.kntro.reqsai.workspace.interfaces.rest.dto.response.OrganizationAuthorizationResponse; +import com.kntro.reqsai.workspace.interfaces.rest.mappers.response.OrganizationAuthorizationResponseMapper; +import com.kntro.reqsai.workspace.interfaces.rest.swagger.OrganizationAuthorizationController; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +/** Implementation of the {@link OrganizationAuthorizationController} API contract. */ +@RestController +@RequiredArgsConstructor +public class OrganizationAuthorizationControllerImpl implements OrganizationAuthorizationController { + + private final GetOrganizationQueryHandler getOrganization; + private final ChangeMemberBasePermissionCommandHandler changeBasePermission; + private final GetOrganizationAuthorizationQueryHandler getAuthorization; + + @Override + @PreAuthorize("@authz.orgOwnerOrAdmin(#orgId, authentication)") + public ResponseEntity getBasePermission(UUID orgId, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + Organization organization = getOrganization.handle(new GetOrganizationQuery(orgId, requestedBy)); + return ResponseEntity.ok( + OrganizationAuthorizationResponseMapper.toResponse(organization.getMemberBasePermission())); + } + + @Override + @PreAuthorize("@authz.orgOwnerOrAdmin(#orgId, authentication)") + public ResponseEntity updateBasePermission( + UUID orgId, UpdateBasePermissionRequest request, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + Organization organization = changeBasePermission.handle( + new ChangeMemberBasePermissionCommand(orgId, request.basePermission(), requestedBy)); + return ResponseEntity.ok( + OrganizationAuthorizationResponseMapper.toResponse(organization.getMemberBasePermission())); + } + + @Override + @PreAuthorize("@authz.orgMember(#orgId, authentication)") + public ResponseEntity getMyAuthorization( + UUID orgId, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + OrganizationAuthorization authorization = getAuthorization.handle( + new GetOrganizationAuthorizationQuery(orgId, requestedBy)); + return ResponseEntity.ok(OrganizationAuthorizationResponseMapper.toResponse(authorization)); + } +} diff --git a/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/dto/request/UpdateBasePermissionRequest.java b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/dto/request/UpdateBasePermissionRequest.java new file mode 100644 index 00000000..cab88cf8 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/dto/request/UpdateBasePermissionRequest.java @@ -0,0 +1,16 @@ +package com.kntro.reqsai.workspace.interfaces.rest.dto.request; + +import com.kntro.reqsai.workspace.domain.model.BasePermission; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "Request body to set the organization's member base permission floor") +public record UpdateBasePermissionRequest( + + @Schema( + description = "RBAC floor applied to every project member on top of their project role", + example = "READ", + allowableValues = {"NONE", "READ"}, + requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull BasePermission basePermission +) {} diff --git a/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/dto/response/BasePermissionResponse.java b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/dto/response/BasePermissionResponse.java new file mode 100644 index 00000000..99e996ed --- /dev/null +++ b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/dto/response/BasePermissionResponse.java @@ -0,0 +1,14 @@ +package com.kntro.reqsai.workspace.interfaces.rest.dto.response; + +import com.kntro.reqsai.workspace.domain.model.BasePermission; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "The organization's member base permission floor") +public record BasePermissionResponse( + + @Schema( + description = "RBAC floor applied to every project member on top of their project role", + example = "READ", + allowableValues = {"NONE", "READ"}) + BasePermission basePermission +) {} diff --git a/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/dto/response/OrganizationAuthorizationResponse.java b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/dto/response/OrganizationAuthorizationResponse.java new file mode 100644 index 00000000..984791ff --- /dev/null +++ b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/dto/response/OrganizationAuthorizationResponse.java @@ -0,0 +1,21 @@ +package com.kntro.reqsai.workspace.interfaces.rest.dto.response; + +import com.kntro.reqsai.workspace.domain.model.BasePermission; +import com.kntro.reqsai.workspace.domain.model.OrgRole; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "The caller's authorization context within an organization") +public record OrganizationAuthorizationResponse( + + @Schema( + description = "The caller's role in the organization", + example = "MEMBER", + allowableValues = {"OWNER", "ADMIN", "MEMBER"}) + OrgRole orgRole, + + @Schema( + description = "The organization's member base permission floor", + example = "READ", + allowableValues = {"NONE", "READ"}) + BasePermission memberBasePermission +) {} diff --git a/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/mappers/response/OrganizationAuthorizationResponseMapper.java b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/mappers/response/OrganizationAuthorizationResponseMapper.java new file mode 100644 index 00000000..0df60d22 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/mappers/response/OrganizationAuthorizationResponseMapper.java @@ -0,0 +1,24 @@ +package com.kntro.reqsai.workspace.interfaces.rest.mappers.response; + +import com.kntro.reqsai.workspace.application.result.OrganizationAuthorization; +import com.kntro.reqsai.workspace.domain.model.BasePermission; +import com.kntro.reqsai.workspace.interfaces.rest.dto.response.BasePermissionResponse; +import com.kntro.reqsai.workspace.interfaces.rest.dto.response.OrganizationAuthorizationResponse; + +/** Maps organization authorization results to their response DTOs. */ +public final class OrganizationAuthorizationResponseMapper { + + private OrganizationAuthorizationResponseMapper() { + throw new UnsupportedOperationException("Utility class - do not instantiate"); + } + + public static BasePermissionResponse toResponse(BasePermission basePermission) { + return new BasePermissionResponse(basePermission); + } + + public static OrganizationAuthorizationResponse toResponse(OrganizationAuthorization authorization) { + return new OrganizationAuthorizationResponse( + authorization.orgRole(), + authorization.memberBasePermission()); + } +} diff --git a/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/swagger/OrganizationAuthorizationController.java b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/swagger/OrganizationAuthorizationController.java new file mode 100644 index 00000000..c09be779 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/swagger/OrganizationAuthorizationController.java @@ -0,0 +1,107 @@ +package com.kntro.reqsai.workspace.interfaces.rest.swagger; + +import com.kntro.reqsai.shared.infrastructure.configuration.ApiVersioning; +import com.kntro.reqsai.shared.infrastructure.documentation.openapi.OpenApiConfiguration; +import com.kntro.reqsai.shared.infrastructure.documentation.openapi.annotations.ApiResponseBadRequest; +import com.kntro.reqsai.shared.infrastructure.documentation.openapi.annotations.ApiResponseNotFound; +import com.kntro.reqsai.shared.infrastructure.documentation.openapi.annotations.ApiStandardErrorResponses; +import com.kntro.reqsai.workspace.interfaces.rest.dto.request.UpdateBasePermissionRequest; +import com.kntro.reqsai.workspace.interfaces.rest.dto.response.BasePermissionResponse; +import com.kntro.reqsai.workspace.interfaces.rest.dto.response.OrganizationAuthorizationResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.util.UUID; + +/** + * API contract for the organization's authorization configuration: the member base-permission floor + * (owner/admin managed) and the caller's own authorization context. Implementation lives in + * {@code controllers.OrganizationAuthorizationControllerImpl}. + */ +@RequestMapping(path = ApiVersioning.BASE + "/organizations", produces = MediaType.APPLICATION_JSON_VALUE) +@Tag(name = "Organization Authorization", description = "Member base permission floor and caller authorization context") +public interface OrganizationAuthorizationController { + + @Operation( + summary = "Get the member base permission", + description = """ + Returns the organization's member base permission floor — the GitHub-style RBAC \ + baseline applied to every project member on top of their explicit project role. + + - `NONE` — members get nothing but their explicit project role + - `READ` — members get a read-only baseline across the workspace resources + - Owner/admin only.""") + @ApiResponse( + responseCode = "200", + description = "The current base permission", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = BasePermissionResponse.class), + examples = @ExampleObject(value = "{ \"basePermission\": \"READ\" }"))) + @ApiResponseNotFound + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @GetMapping(value = "/{orgId}/base-permission", version = ApiVersioning.V1) + ResponseEntity getBasePermission( + @PathVariable UUID orgId, Authentication authentication); + + @Operation( + summary = "Set the member base permission", + description = """ + Sets the organization's member base permission floor. Applies to every project \ + member immediately; owners/admins bypass it and keep full access. + + - Body: `{ "basePermission": "NONE" | "READ" }` + - Owner/admin only.""") + @ApiResponse( + responseCode = "200", + description = "The updated base permission", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = BasePermissionResponse.class), + examples = @ExampleObject(value = "{ \"basePermission\": \"NONE\" }"))) + @ApiResponseBadRequest + @ApiResponseNotFound + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @PutMapping(value = "/{orgId}/base-permission", version = ApiVersioning.V1) + ResponseEntity updateBasePermission( + @PathVariable UUID orgId, + @Valid @RequestBody UpdateBasePermissionRequest request, + Authentication authentication); + + @Operation( + summary = "Get my organization authorization", + description = """ + Returns the caller's authorization context in the organization: their org role \ + (`OWNER`/`ADMIN`/`MEMBER`) and the organization's member base-permission floor. + + - Any organization member (owner or active member) may read this.""") + @ApiResponse( + responseCode = "200", + description = "The caller's authorization context", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = OrganizationAuthorizationResponse.class), + examples = @ExampleObject(value = "{ \"orgRole\": \"MEMBER\", \"memberBasePermission\": \"READ\" }"))) + @ApiResponseNotFound + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @GetMapping(value = "/{orgId}/me/authorization", version = ApiVersioning.V1) + ResponseEntity getMyAuthorization( + @PathVariable UUID orgId, Authentication authentication); +} From 6a4c5b09b84513a79f2a228e2ae1df3851d83e9a Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Fri, 10 Jul 2026 08:46:23 -0500 Subject: [PATCH 04/13] feat(workspace): expose the caller's effective project permissions endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../workspace/api/WorkspaceModuleApi.java | 8 +++ .../GetMyProjectPermissionsQueryHandler.java | 53 +++++++++++++++++ .../query/GetMyProjectPermissionsQuery.java | 9 +++ .../service/WorkspaceModuleApiImpl.java | 13 +++++ .../ProjectPermissionsControllerImpl.java | 33 +++++++++++ .../MyProjectPermissionsResponse.java | 14 +++++ .../rest/security/WorkspaceAuthorization.java | 11 ++++ .../swagger/ProjectPermissionsController.java | 57 +++++++++++++++++++ 8 files changed, 198 insertions(+) create mode 100644 src/main/java/com/kntro/reqsai/workspace/application/handler/GetMyProjectPermissionsQueryHandler.java create mode 100644 src/main/java/com/kntro/reqsai/workspace/application/query/GetMyProjectPermissionsQuery.java create mode 100644 src/main/java/com/kntro/reqsai/workspace/interfaces/rest/controllers/ProjectPermissionsControllerImpl.java create mode 100644 src/main/java/com/kntro/reqsai/workspace/interfaces/rest/dto/response/MyProjectPermissionsResponse.java create mode 100644 src/main/java/com/kntro/reqsai/workspace/interfaces/rest/swagger/ProjectPermissionsController.java diff --git a/src/main/java/com/kntro/reqsai/workspace/api/WorkspaceModuleApi.java b/src/main/java/com/kntro/reqsai/workspace/api/WorkspaceModuleApi.java index a6b61c4c..9348834e 100644 --- a/src/main/java/com/kntro/reqsai/workspace/api/WorkspaceModuleApi.java +++ b/src/main/java/com/kntro/reqsai/workspace/api/WorkspaceModuleApi.java @@ -41,6 +41,14 @@ public interface WorkspaceModuleApi { */ boolean callerHasProjectPermission(UUID projectId, UUID userId, String permission); + /** + * Whether {@code userId} may access the given project of the currently bound tenant: org + * owners/admins always may; a regular member needs an explicit project assignment. Returns + * {@code false} when no tenant is bound or the organization is unknown. Coarse project-access gate + * for routes carrying no {@code orgId} path variable (e.g. {@code /api/projects/{projectId}/...}). + */ + boolean callerCanAccessProject(UUID projectId, UUID userId); + /** * Resolves the roster display name of an active member by organization and user id. Used by * discovery's live-session presence to label participants without reaching into the workspace diff --git a/src/main/java/com/kntro/reqsai/workspace/application/handler/GetMyProjectPermissionsQueryHandler.java b/src/main/java/com/kntro/reqsai/workspace/application/handler/GetMyProjectPermissionsQueryHandler.java new file mode 100644 index 00000000..7048d03a --- /dev/null +++ b/src/main/java/com/kntro/reqsai/workspace/application/handler/GetMyProjectPermissionsQueryHandler.java @@ -0,0 +1,53 @@ +package com.kntro.reqsai.workspace.application.handler; + +import com.kntro.reqsai.shared.infrastructure.persistence.multitenancy.TenantContext; +import com.kntro.reqsai.workspace.application.port.OrganizationRepository; +import com.kntro.reqsai.workspace.application.query.GetMyProjectPermissionsQuery; +import com.kntro.reqsai.workspace.application.service.ProjectPermissionService; +import com.kntro.reqsai.workspace.domain.exception.WorkspaceExceptions; +import com.kntro.reqsai.workspace.domain.model.Organization; +import com.kntro.reqsai.workspace.domain.model.Permission; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Set; +import java.util.UUID; + +/** + * Resolves the caller's effective permissions on a project. The organization is taken from the tenant + * bound to the request (routes carry no {@code orgId}); the effective set is the union of the base + * floor and the caller's project role (all permissions for owners/admins). + */ +@Component +@RequiredArgsConstructor +public class GetMyProjectPermissionsQueryHandler { + + private final OrganizationRepository organizations; + private final ProjectPermissionService projectPermissions; + + @Transactional(readOnly = true) + public Set handle(GetMyProjectPermissionsQuery query) { + UUID orgId = currentTenantOrgId(); + if (orgId == null) { + throw WorkspaceExceptions.insufficientPermissions( + "read project permissions", query.requestedBy()); + } + Organization organization = organizations.findById(orgId) + .orElseThrow(() -> WorkspaceExceptions.organizationNotFound(orgId)); + + return projectPermissions.effectivePermissions(organization, query.projectId(), query.requestedBy()); + } + + private static UUID currentTenantOrgId() { + String tenant = TenantContext.getCurrentTenant(); + if (tenant == null) { + return null; + } + try { + return UUID.fromString(tenant); + } catch (IllegalArgumentException ex) { + return null; + } + } +} diff --git a/src/main/java/com/kntro/reqsai/workspace/application/query/GetMyProjectPermissionsQuery.java b/src/main/java/com/kntro/reqsai/workspace/application/query/GetMyProjectPermissionsQuery.java new file mode 100644 index 00000000..efb19b6b --- /dev/null +++ b/src/main/java/com/kntro/reqsai/workspace/application/query/GetMyProjectPermissionsQuery.java @@ -0,0 +1,9 @@ +package com.kntro.reqsai.workspace.application.query; + +import java.util.UUID; + +/** Resolves the caller's effective permissions on a project (of the currently bound tenant). */ +public record GetMyProjectPermissionsQuery( + UUID projectId, + UUID requestedBy +) {} diff --git a/src/main/java/com/kntro/reqsai/workspace/application/service/WorkspaceModuleApiImpl.java b/src/main/java/com/kntro/reqsai/workspace/application/service/WorkspaceModuleApiImpl.java index 9ae7de38..b39f409b 100644 --- a/src/main/java/com/kntro/reqsai/workspace/application/service/WorkspaceModuleApiImpl.java +++ b/src/main/java/com/kntro/reqsai/workspace/application/service/WorkspaceModuleApiImpl.java @@ -33,6 +33,7 @@ class WorkspaceModuleApiImpl implements WorkspaceModuleApi { private final WorkspaceSearchRepository searchRepository; private final OrganizationRepository organizations; private final ProjectPermissionService projectPermissions; + private final ProjectAccessService projectAccess; private final MemberRepository members; @Override @@ -90,6 +91,18 @@ public boolean callerHasProjectPermission(UUID projectId, UUID userId, String pe .orElse(false); } + @Override + @Transactional(readOnly = true) + public boolean callerCanAccessProject(UUID projectId, UUID userId) { + UUID orgId = currentTenantOrgId(); + if (orgId == null) { + return false; + } + return organizations.findById(orgId) + .map(org -> projectAccess.canAccessProject(org, projectId, userId)) + .orElse(false); + } + @Override @Transactional(readOnly = true) public Optional findMemberDisplayName(UUID organizationId, UUID userId) { diff --git a/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/controllers/ProjectPermissionsControllerImpl.java b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/controllers/ProjectPermissionsControllerImpl.java new file mode 100644 index 00000000..e407ef51 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/controllers/ProjectPermissionsControllerImpl.java @@ -0,0 +1,33 @@ +package com.kntro.reqsai.workspace.interfaces.rest.controllers; + +import com.kntro.reqsai.workspace.application.handler.GetMyProjectPermissionsQueryHandler; +import com.kntro.reqsai.workspace.application.query.GetMyProjectPermissionsQuery; +import com.kntro.reqsai.workspace.domain.model.Permission; +import com.kntro.reqsai.workspace.interfaces.rest.dto.response.MyProjectPermissionsResponse; +import com.kntro.reqsai.workspace.interfaces.rest.swagger.ProjectPermissionsController; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Set; +import java.util.UUID; + +/** Implementation of the {@link ProjectPermissionsController} API contract. */ +@RestController +@RequiredArgsConstructor +public class ProjectPermissionsControllerImpl implements ProjectPermissionsController { + + private final GetMyProjectPermissionsQueryHandler getMyPermissions; + + @Override + @PreAuthorize("@authz.projectAccess(#projectId, authentication)") + public ResponseEntity getMyPermissions( + UUID projectId, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + Set permissions = getMyPermissions.handle( + new GetMyProjectPermissionsQuery(projectId, requestedBy)); + return ResponseEntity.ok(new MyProjectPermissionsResponse(permissions)); + } +} diff --git a/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/dto/response/MyProjectPermissionsResponse.java b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/dto/response/MyProjectPermissionsResponse.java new file mode 100644 index 00000000..6b120654 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/dto/response/MyProjectPermissionsResponse.java @@ -0,0 +1,14 @@ +package com.kntro.reqsai.workspace.interfaces.rest.dto.response; + +import com.kntro.reqsai.workspace.domain.model.Permission; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.Set; + +@Schema(description = "The caller's effective permissions on a project") +public record MyProjectPermissionsResponse( + + @Schema(description = "The permissions the caller effectively holds on the project", + example = "[\"STORY_READ\", \"DOCUMENT_READ\"]") + Set permissions +) {} diff --git a/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/security/WorkspaceAuthorization.java b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/security/WorkspaceAuthorization.java index b01a7ed2..210b5468 100644 --- a/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/security/WorkspaceAuthorization.java +++ b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/security/WorkspaceAuthorization.java @@ -60,6 +60,17 @@ public boolean projectAccess(UUID orgId, UUID projectId, Authentication authenti (org, userId) -> projectAccess.canAccessProject(org, projectId, userId)); } + /** + * Caller may access the given project of the current tenant (the JWT {@code orgId} bound by + * the authentication filter). For routes that carry no {@code orgId} path variable, e.g. + * {@code /api/projects/{projectId}/me/permissions}. Owner/admin bypass is identical to + * {@link #projectAccess(UUID, UUID, Authentication)}; denies when no tenant is bound to the request. + */ + public boolean projectAccess(UUID projectId, Authentication authentication) { + UUID userId = callerId(authentication); + return userId != null && moduleApi.callerCanAccessProject(projectId, userId); + } + /** Caller holds the named {@link Permission} on the given project. */ public boolean projectPermission( UUID orgId, UUID projectId, String permission, Authentication authentication) { diff --git a/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/swagger/ProjectPermissionsController.java b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/swagger/ProjectPermissionsController.java new file mode 100644 index 00000000..0e0eb09c --- /dev/null +++ b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/swagger/ProjectPermissionsController.java @@ -0,0 +1,57 @@ +package com.kntro.reqsai.workspace.interfaces.rest.swagger; + +import com.kntro.reqsai.shared.infrastructure.configuration.ApiVersioning; +import com.kntro.reqsai.shared.infrastructure.documentation.openapi.OpenApiConfiguration; +import com.kntro.reqsai.shared.infrastructure.documentation.openapi.annotations.ApiResponseNotFound; +import com.kntro.reqsai.shared.infrastructure.documentation.openapi.annotations.ApiStandardErrorResponses; +import com.kntro.reqsai.workspace.interfaces.rest.dto.response.MyProjectPermissionsResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.util.UUID; + +/** + * API contract for the caller's effective project permissions. The route carries no {@code orgId}; + * the tenant is taken from the JWT. Implementation lives in + * {@code controllers.ProjectPermissionsControllerImpl}. + */ +@RequestMapping(path = ApiVersioning.BASE + "/projects", produces = MediaType.APPLICATION_JSON_VALUE) +@Tag(name = "Project Permissions", description = "The caller's effective permissions on a project") +public interface ProjectPermissionsController { + + @Operation( + summary = "Get my effective project permissions", + description = """ + Returns the caller's effective permissions on the project — the union of the \ + organization's member base-permission floor and the caller's project role. \ + Owners/admins receive the full permission catalog. + + - Any member who can access the project (or an org owner/admin) may read their own \ + permissions.""") + @ApiResponse( + responseCode = "200", + description = "The caller's effective permissions", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = MyProjectPermissionsResponse.class), + examples = @ExampleObject(value = "{ \"permissions\": [\"STORY_READ\", \"DOCUMENT_READ\"] }"))) + @ApiResponseNotFound + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @GetMapping(value = "/{projectId}/me/permissions", version = ApiVersioning.V1) + ResponseEntity getMyPermissions( + @Parameter(description = "Project UUID") @PathVariable UUID projectId, + Authentication authentication); +} From 4662f4252426c7ca1462aa166cd923cfa15ac850 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Fri, 10 Jul 2026 08:50:09 -0500 Subject: [PATCH 05/13] test(workspace): cover the member base permission floor and effective 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. --- .../service/ProjectPermissionServiceTest.java | 112 ++++++++++++++++++ .../domain/model/BasePermissionTest.java | 45 +++++++ .../domain/model/OrganizationTest.java | 30 +++++ .../mothers/OrganizationBuilder.java | 11 ++ 4 files changed, 198 insertions(+) create mode 100644 src/test/java/com/kntro/reqsai/workspace/domain/model/BasePermissionTest.java diff --git a/src/test/java/com/kntro/reqsai/workspace/application/service/ProjectPermissionServiceTest.java b/src/test/java/com/kntro/reqsai/workspace/application/service/ProjectPermissionServiceTest.java index 24cc3ce6..0418a2c1 100644 --- a/src/test/java/com/kntro/reqsai/workspace/application/service/ProjectPermissionServiceTest.java +++ b/src/test/java/com/kntro/reqsai/workspace/application/service/ProjectPermissionServiceTest.java @@ -4,6 +4,7 @@ import com.kntro.reqsai.workspace.application.port.MemberRepository; import com.kntro.reqsai.workspace.application.port.ProjectMemberRepository; import com.kntro.reqsai.workspace.application.port.ProjectRoleRepository; +import com.kntro.reqsai.workspace.domain.model.BasePermission; import com.kntro.reqsai.workspace.domain.model.Member; import com.kntro.reqsai.workspace.domain.model.MemberStatus; import com.kntro.reqsai.workspace.domain.model.OrgRole; @@ -24,9 +25,11 @@ import java.util.Set; import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.lenient; @DisplayName("Service: Project Permission") @ExtendWith(MockitoExtension.class) @@ -139,4 +142,113 @@ void member_without_assignment_denied() { org, projectId, memberUser, Permission.ROLE_CREATE, "manage project roles")) .isInstanceOf(DomainException.class); } + + // --- Member base permission floor ------------------------------------------------------------- + + @Test + @DisplayName("base READ lets a role-less active member READ but not WRITE") + void base_read_grants_read_only_to_role_less_member() { + Organization org = OrganizationMother.active().withMemberBasePermission(BasePermission.READ).build(); + UUID memberUser = UUID.randomUUID(); + UUID projectId = UUID.randomUUID(); + Member m = member(org.getId(), memberUser, OrgRole.MEMBER); + + lenient().when(orgMembers.findByOrganizationIdAndUserIdAndStatus(org.getId(), memberUser, MemberStatus.ACTIVE)) + .thenReturn(Optional.of(m)); + when(members.findByOrganizationIdAndUserIdAndStatus(org.getId(), memberUser, MemberStatus.ACTIVE)) + .thenReturn(Optional.of(m)); + lenient().when(assignments.findAllByMemberId(m.getId())).thenReturn(List.of()); + + assertThat(service().hasPermission(org, projectId, memberUser, Permission.STORY_READ)).isTrue(); + assertThat(service().hasPermission(org, projectId, memberUser, Permission.STORY_WRITE)).isFalse(); + } + + @Test + @DisplayName("base NONE denies even READ to a role-less member") + void base_none_denies_read_to_role_less_member() { + Organization org = OrganizationMother.active().withMemberBasePermission(BasePermission.NONE).build(); + UUID memberUser = UUID.randomUUID(); + UUID projectId = UUID.randomUUID(); + Member m = member(org.getId(), memberUser, OrgRole.MEMBER); + + lenient().when(orgMembers.findByOrganizationIdAndUserIdAndStatus(org.getId(), memberUser, MemberStatus.ACTIVE)) + .thenReturn(Optional.of(m)); + when(members.findByOrganizationIdAndUserIdAndStatus(org.getId(), memberUser, MemberStatus.ACTIVE)) + .thenReturn(Optional.of(m)); + lenient().when(assignments.findAllByMemberId(m.getId())).thenReturn(List.of()); + + assertThat(service().hasPermission(org, projectId, memberUser, Permission.STORY_READ)).isFalse(); + } + + @Test + @DisplayName("base READ does not grant a non-member the floor") + void base_read_denies_non_member() { + Organization org = OrganizationMother.active().withMemberBasePermission(BasePermission.READ).build(); + UUID stranger = UUID.randomUUID(); + UUID projectId = UUID.randomUUID(); + + when(orgMembers.findByOrganizationIdAndUserIdAndStatus(org.getId(), stranger, MemberStatus.ACTIVE)) + .thenReturn(Optional.empty()); + when(members.findByOrganizationIdAndUserIdAndStatus(org.getId(), stranger, MemberStatus.ACTIVE)) + .thenReturn(Optional.empty()); + + assertThat(service().hasPermission(org, projectId, stranger, Permission.STORY_READ)).isFalse(); + } + + @Test + @DisplayName("owner passes any permission regardless of base NONE") + void owner_unaffected_by_base_none() { + Organization org = OrganizationMother.active().withMemberBasePermission(BasePermission.NONE).build(); + + assertThat(service().hasPermission(org, UUID.randomUUID(), org.getOwnerId(), Permission.STORY_WRITE)).isTrue(); + } + + // --- effectivePermissions --------------------------------------------------------------------- + + @Test + @DisplayName("effectivePermissions returns the full catalog for an owner") + void effective_permissions_owner_gets_all() { + Organization org = OrganizationMother.active().withMemberBasePermission(BasePermission.NONE).build(); + + assertThat(service().effectivePermissions(org, UUID.randomUUID(), org.getOwnerId())) + .containsExactlyInAnyOrder(Permission.values()); + } + + @Test + @DisplayName("effectivePermissions unions the base floor with the project role") + void effective_permissions_union_base_and_role() { + Organization org = OrganizationMother.active().withMemberBasePermission(BasePermission.READ).build(); + UUID memberUser = UUID.randomUUID(); + UUID projectId = UUID.randomUUID(); + UUID roleId = UUID.randomUUID(); + Member m = member(org.getId(), memberUser, OrgRole.MEMBER); + + lenient().when(orgMembers.findByOrganizationIdAndUserIdAndStatus(org.getId(), memberUser, MemberStatus.ACTIVE)) + .thenReturn(Optional.of(m)); + when(members.findByOrganizationIdAndUserIdAndStatus(org.getId(), memberUser, MemberStatus.ACTIVE)) + .thenReturn(Optional.of(m)); + when(assignments.findAllByMemberId(m.getId())) + .thenReturn(List.of(new ProjectMember(projectId, m.getId(), roleId, UUID.randomUUID(), Instant.now()))); + when(roles.findByIdAndProjectId(roleId, projectId)) + .thenReturn(Optional.of(new ProjectRole(projectId, "Writer", Set.of(Permission.STORY_WRITE)))); + + assertThat(service().effectivePermissions(org, projectId, memberUser)) + .contains(Permission.STORY_WRITE) // from the project role + .contains(Permission.STORY_READ, Permission.MEMBER_READ) // from the READ floor + .doesNotContain(Permission.DOCUMENT_CREATE); + } + + @Test + @DisplayName("effectivePermissions is empty for a non-member") + void effective_permissions_non_member_empty() { + Organization org = OrganizationMother.active().withMemberBasePermission(BasePermission.READ).build(); + UUID stranger = UUID.randomUUID(); + + when(orgMembers.findByOrganizationIdAndUserIdAndStatus(org.getId(), stranger, MemberStatus.ACTIVE)) + .thenReturn(Optional.empty()); + when(members.findByOrganizationIdAndUserIdAndStatus(org.getId(), stranger, MemberStatus.ACTIVE)) + .thenReturn(Optional.empty()); + + assertThat(service().effectivePermissions(org, UUID.randomUUID(), stranger)).isEmpty(); + } } diff --git a/src/test/java/com/kntro/reqsai/workspace/domain/model/BasePermissionTest.java b/src/test/java/com/kntro/reqsai/workspace/domain/model/BasePermissionTest.java new file mode 100644 index 00000000..34f45625 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/workspace/domain/model/BasePermissionTest.java @@ -0,0 +1,45 @@ +package com.kntro.reqsai.workspace.domain.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("Domain: BasePermission floor") +class BasePermissionTest { + + @Test + @DisplayName("NONE grants nothing") + void none_grants_nothing() { + assertThat(BasePermission.NONE.grantedPermissions()).isEmpty(); + } + + @Test + @DisplayName("READ grants exactly the workspace *_READ baseline (integration read excluded)") + void read_grants_read_baseline() { + assertThat(BasePermission.READ.grantedPermissions()).containsExactlyInAnyOrder( + Permission.MEMBER_READ, + Permission.ROLE_READ, + Permission.DOCUMENT_READ, + Permission.GLOSSARY_READ, + Permission.CONSTRAINT_READ, + Permission.SESSION_READ, + Permission.STORY_READ); + } + + @Test + @DisplayName("READ grants no write, manage or integration permission") + void read_excludes_writes_and_integration() { + Set granted = BasePermission.READ.grantedPermissions(); + assertThat(granted) + .doesNotContain( + Permission.MEMBER_INVITE, + Permission.ROLE_CREATE, + Permission.DOCUMENT_CREATE, + Permission.STORY_WRITE, + Permission.SESSION_RUN, + Permission.INTEGRATION_READ); + } +} diff --git a/src/test/java/com/kntro/reqsai/workspace/domain/model/OrganizationTest.java b/src/test/java/com/kntro/reqsai/workspace/domain/model/OrganizationTest.java index 5713f3c7..eeeed5e5 100644 --- a/src/test/java/com/kntro/reqsai/workspace/domain/model/OrganizationTest.java +++ b/src/test/java/com/kntro/reqsai/workspace/domain/model/OrganizationTest.java @@ -60,6 +60,14 @@ void should_expose_settings_as_value_objects() { assertThat(org.getSettings().audioRetentionDays()).isEqualTo(7); assertThat(org.getPlanLimits().maxProjects()).isEqualTo(25); } + + @Test + @DisplayName("should default the member base permission to READ") + void should_default_member_base_permission_to_read() { + Organization org = OrganizationMother.pending().build(); + + assertThat(org.getMemberBasePermission()).isEqualTo(BasePermission.READ); + } } @Nested @@ -115,6 +123,28 @@ void should_rename_organization() { assertThat(org.getName()).isEqualTo("Acme International"); } + @Test + @DisplayName("should change the member base permission floor") + void should_change_member_base_permission() { + // Arrange + Organization org = OrganizationMother.pending().build(); + + // Act + org.changeMemberBasePermission(BasePermission.NONE); + + // Assert + assertThat(org.getMemberBasePermission()).isEqualTo(BasePermission.NONE); + } + + @Test + @DisplayName("should reject a null member base permission") + void should_reject_null_member_base_permission() { + Organization org = OrganizationMother.pending().build(); + + assertThatThrownBy(() -> org.changeMemberBasePermission(null)) + .isInstanceOf(DomainException.class); + } + @Test @DisplayName("should replace generation settings") void should_replace_settings() { diff --git a/src/test/java/com/kntro/reqsai/workspace/mothers/OrganizationBuilder.java b/src/test/java/com/kntro/reqsai/workspace/mothers/OrganizationBuilder.java index b8177691..5fa36fc6 100644 --- a/src/test/java/com/kntro/reqsai/workspace/mothers/OrganizationBuilder.java +++ b/src/test/java/com/kntro/reqsai/workspace/mothers/OrganizationBuilder.java @@ -1,6 +1,7 @@ package com.kntro.reqsai.workspace.mothers; import com.kntro.reqsai.shared.domain.valueobjects.LanguageCode; +import com.kntro.reqsai.workspace.domain.model.BasePermission; import com.kntro.reqsai.workspace.domain.model.Organization; import com.kntro.reqsai.workspace.domain.valueobjects.GenerationSettings; import com.kntro.reqsai.workspace.domain.valueobjects.PlanLimits; @@ -23,6 +24,7 @@ public class OrganizationBuilder { private LanguageCode meetingLanguage = LanguageCode.of(FAKER.options().option("es-PE", "en-US", "pt-BR")); private int audioRetentionDays = FAKER.number().numberBetween(0, 90); private PlanLimits planLimits = new PlanLimits(3, 25, 10, 100_000L, 50); + private BasePermission memberBasePermission; private boolean active; public static OrganizationBuilder anOrganization() { @@ -59,6 +61,12 @@ public OrganizationBuilder withPlanLimits(PlanLimits planLimits) { return this; } + /** Override the organization-wide member base permission floor (defaults to {@code READ}). */ + public OrganizationBuilder withMemberBasePermission(BasePermission memberBasePermission) { + this.memberBasePermission = memberBasePermission; + return this; + } + /** Build the org already {@code ACTIVE} (provisioning done), instead of the default {@code PENDING}. */ public OrganizationBuilder active() { this.active = true; @@ -68,6 +76,9 @@ public OrganizationBuilder active() { public Organization build() { Organization organization = new Organization( name, slug, ownerId, GenerationSettings.of(meetingLanguage, audioRetentionDays), planLimits); + if (memberBasePermission != null) { + organization.changeMemberBasePermission(memberBasePermission); + } if (active) { organization.activate(); } From 88171d3bed7b76de4c194a08b208bd64ed40ae5a Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Fri, 10 Jul 2026 08:55:09 -0500 Subject: [PATCH 06/13] refactor(workspace): gate me/permissions on tenant membership, not project access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../reqsai/workspace/api/WorkspaceModuleApi.java | 10 +++++----- .../application/service/WorkspaceModuleApiImpl.java | 6 +++--- .../ProjectPermissionsControllerImpl.java | 2 +- .../rest/security/WorkspaceAuthorization.java | 12 ++++++------ .../rest/swagger/ProjectPermissionsController.java | 4 ++-- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/kntro/reqsai/workspace/api/WorkspaceModuleApi.java b/src/main/java/com/kntro/reqsai/workspace/api/WorkspaceModuleApi.java index 9348834e..ea27052b 100644 --- a/src/main/java/com/kntro/reqsai/workspace/api/WorkspaceModuleApi.java +++ b/src/main/java/com/kntro/reqsai/workspace/api/WorkspaceModuleApi.java @@ -42,12 +42,12 @@ public interface WorkspaceModuleApi { boolean callerHasProjectPermission(UUID projectId, UUID userId, String permission); /** - * Whether {@code userId} may access the given project of the currently bound tenant: org - * owners/admins always may; a regular member needs an explicit project assignment. Returns - * {@code false} when no tenant is bound or the organization is unknown. Coarse project-access gate - * for routes carrying no {@code orgId} path variable (e.g. {@code /api/projects/{projectId}/...}). + * Whether {@code userId} is an active member — the owner or an ACTIVE member row — of the + * currently bound tenant. Returns {@code false} when no tenant is bound or the organization + * is unknown. Coarse org-membership gate for member-scoped routes carrying no {@code orgId} path + * variable (e.g. {@code /api/projects/{projectId}/me/permissions}). */ - boolean callerCanAccessProject(UUID projectId, UUID userId); + boolean callerIsActiveMember(UUID userId); /** * Resolves the roster display name of an active member by organization and user id. Used by diff --git a/src/main/java/com/kntro/reqsai/workspace/application/service/WorkspaceModuleApiImpl.java b/src/main/java/com/kntro/reqsai/workspace/application/service/WorkspaceModuleApiImpl.java index b39f409b..8574f882 100644 --- a/src/main/java/com/kntro/reqsai/workspace/application/service/WorkspaceModuleApiImpl.java +++ b/src/main/java/com/kntro/reqsai/workspace/application/service/WorkspaceModuleApiImpl.java @@ -33,7 +33,7 @@ class WorkspaceModuleApiImpl implements WorkspaceModuleApi { private final WorkspaceSearchRepository searchRepository; private final OrganizationRepository organizations; private final ProjectPermissionService projectPermissions; - private final ProjectAccessService projectAccess; + private final OrganizationAdminAccessService orgAccess; private final MemberRepository members; @Override @@ -93,13 +93,13 @@ public boolean callerHasProjectPermission(UUID projectId, UUID userId, String pe @Override @Transactional(readOnly = true) - public boolean callerCanAccessProject(UUID projectId, UUID userId) { + public boolean callerIsActiveMember(UUID userId) { UUID orgId = currentTenantOrgId(); if (orgId == null) { return false; } return organizations.findById(orgId) - .map(org -> projectAccess.canAccessProject(org, projectId, userId)) + .map(org -> orgAccess.isMember(org, userId)) .orElse(false); } diff --git a/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/controllers/ProjectPermissionsControllerImpl.java b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/controllers/ProjectPermissionsControllerImpl.java index e407ef51..39d62dcb 100644 --- a/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/controllers/ProjectPermissionsControllerImpl.java +++ b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/controllers/ProjectPermissionsControllerImpl.java @@ -22,7 +22,7 @@ public class ProjectPermissionsControllerImpl implements ProjectPermissionsContr private final GetMyProjectPermissionsQueryHandler getMyPermissions; @Override - @PreAuthorize("@authz.projectAccess(#projectId, authentication)") + @PreAuthorize("@authz.tenantMember(authentication)") public ResponseEntity getMyPermissions( UUID projectId, Authentication authentication) { UUID requestedBy = UUID.fromString(authentication.getName()); diff --git a/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/security/WorkspaceAuthorization.java b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/security/WorkspaceAuthorization.java index 210b5468..76e574ef 100644 --- a/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/security/WorkspaceAuthorization.java +++ b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/security/WorkspaceAuthorization.java @@ -61,14 +61,14 @@ public boolean projectAccess(UUID orgId, UUID projectId, Authentication authenti } /** - * Caller may access the given project of the current tenant (the JWT {@code orgId} bound by - * the authentication filter). For routes that carry no {@code orgId} path variable, e.g. - * {@code /api/projects/{projectId}/me/permissions}. Owner/admin bypass is identical to - * {@link #projectAccess(UUID, UUID, Authentication)}; denies when no tenant is bound to the request. + * Caller is an active member (or owner/admin) of the current tenant (the JWT {@code orgId} + * bound by the authentication filter). For member-scoped routes that carry no {@code orgId} path + * variable, e.g. {@code /api/projects/{projectId}/me/permissions} where any member may read their + * own effective permissions. Denies when no tenant is bound to the request. */ - public boolean projectAccess(UUID projectId, Authentication authentication) { + public boolean tenantMember(Authentication authentication) { UUID userId = callerId(authentication); - return userId != null && moduleApi.callerCanAccessProject(projectId, userId); + return userId != null && moduleApi.callerIsActiveMember(userId); } /** Caller holds the named {@link Permission} on the given project. */ diff --git a/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/swagger/ProjectPermissionsController.java b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/swagger/ProjectPermissionsController.java index 0e0eb09c..e7253381 100644 --- a/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/swagger/ProjectPermissionsController.java +++ b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/swagger/ProjectPermissionsController.java @@ -38,8 +38,8 @@ public interface ProjectPermissionsController { organization's member base-permission floor and the caller's project role. \ Owners/admins receive the full permission catalog. - - Any member who can access the project (or an org owner/admin) may read their own \ - permissions.""") + - Any active organization member may read their own effective permissions \ + (the set is empty for a member with neither a base floor nor a project role).""") @ApiResponse( responseCode = "200", description = "The caller's effective permissions", From ab3c58218b7e35c450fc0fcb8c6a5d87fc9b923a Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Fri, 10 Jul 2026 08:55:22 -0500 Subject: [PATCH 07/13] test(workspace): integration-cover the base permission and effective 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. --- .../rest/BasePermissionIntegrationTest.java | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 src/test/java/com/kntro/reqsai/workspace/interfaces/rest/BasePermissionIntegrationTest.java diff --git a/src/test/java/com/kntro/reqsai/workspace/interfaces/rest/BasePermissionIntegrationTest.java b/src/test/java/com/kntro/reqsai/workspace/interfaces/rest/BasePermissionIntegrationTest.java new file mode 100644 index 00000000..1d4756f0 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/workspace/interfaces/rest/BasePermissionIntegrationTest.java @@ -0,0 +1,264 @@ +package com.kntro.reqsai.workspace.interfaces.rest; + +import com.kntro.reqsai.testsupport.AbstractIntegrationTest; +import com.kntro.reqsai.testsupport.TestJwtFactory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +@Tag("integration") +@DisplayName("Integration: Member base permission floor + effective permission endpoints") +class BasePermissionIntegrationTest extends AbstractIntegrationTest { + + private static final String OWNER_USER_ID = "00000000-0000-0000-0000-000000000001"; + private static final String ADMIN_USER_ID = "00000000-0000-0000-0000-000000000002"; + private static final String MEMBER_USER_ID = "00000000-0000-0000-0000-000000000003"; + private static final String ROLE_MEMBER_USER_ID = "00000000-0000-0000-0000-000000000004"; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + @DisplayName("PUT base-permission: owner can set it, a plain member is forbidden") + void put_base_permission_owner_ok_member_forbidden() { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String slug = "acme-" + suffix; + UUID orgId = createOrganizationAndReturnId(suffix, slug); + + createMember(orgId, OWNER_USER_ID, Map.of( + "userId", MEMBER_USER_ID, "email", "member@example.com", "displayName", "Member", "role", "MEMBER")); + + // Owner sets the floor to NONE + ResponseEntity byOwner = putBasePermission(orgId, OWNER_USER_ID, "NONE"); + assertThat(byOwner.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(byOwner.getBody()).contains("\"basePermission\":\"NONE\""); + + // GET reflects it + ResponseEntity get = getBasePermission(orgId, OWNER_USER_ID); + assertThat(get.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(get.getBody()).contains("\"basePermission\":\"NONE\""); + + // A plain member cannot change or read it + assertThat(putBasePermission(orgId, MEMBER_USER_ID, "READ").getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(getBasePermission(orgId, MEMBER_USER_ID).getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + @DisplayName("role-less member reads a CONSTRAINT_READ endpoint when base=READ, is denied when base=NONE") + void base_floor_gates_a_read_endpoint_for_a_role_less_member() { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String slug = "acme-" + suffix; + UUID orgId = createOrganizationAndReturnId(suffix, slug); + + // A member with NO project assignment at all. + createMember(orgId, OWNER_USER_ID, Map.of( + "userId", MEMBER_USER_ID, "email", "member@example.com", "displayName", "Member", "role", "MEMBER")); + + UUID projectId = createProjectAndReturnId(orgId, slug, "Floor Project"); + + // base=READ (the default) → the role-less member can read constraints. + assertThat(putBasePermission(orgId, OWNER_USER_ID, "READ").getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(listConstraints(orgId, projectId, MEMBER_USER_ID).getStatusCode()).isEqualTo(HttpStatus.OK); + + // base=NONE → the same member is now forbidden. + assertThat(putBasePermission(orgId, OWNER_USER_ID, "NONE").getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(listConstraints(orgId, projectId, MEMBER_USER_ID).getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + @DisplayName("me/permissions: owner gets the full catalog; role-less member gets the floor; role member gets floor + role") + void me_permissions_reflects_owner_floor_and_role() { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String slug = "acme-" + suffix; + String schema = "tenant_" + slug; + UUID orgId = createOrganizationAndReturnId(suffix, slug); + + createMember(orgId, OWNER_USER_ID, Map.of( + "userId", MEMBER_USER_ID, "email", "member@example.com", "displayName", "Member", "role", "MEMBER")); + createMember(orgId, OWNER_USER_ID, Map.of( + "userId", ROLE_MEMBER_USER_ID, "email", "writer@example.com", "displayName", "Writer", "role", "MEMBER")); + UUID writerMemberId = memberId(orgId, "writer@example.com"); + + UUID projectId = createProjectAndReturnId(orgId, slug, "Perms Project"); + + // base=READ so the role-less member has the read floor. + assertThat(putBasePermission(orgId, OWNER_USER_ID, "READ").getStatusCode()).isEqualTo(HttpStatus.OK); + + // A custom role carrying a WRITE the floor does not grant, assigned to the writer member. + String roleId = createRoleAndReturnId(orgId, projectId, OWNER_USER_ID, "Writer", + List.of("CONSTRAINT_READ", "CONSTRAINT_WRITE"), schema); + assignMember(orgId, projectId, OWNER_USER_ID, writerMemberId.toString(), roleId); + + // Owner → full catalog (includes writes and manage permissions). + ResponseEntity owner = myPermissions(projectId, OWNER_USER_ID, orgId); + assertThat(owner.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(owner.getBody()).contains("STORY_READ", "STORY_WRITE", "PROJECT_DELETE", "ROLE_CREATE"); + + // Role-less member → only the READ floor (7 *_READ perms, no writes). + ResponseEntity member = myPermissions(projectId, MEMBER_USER_ID, orgId); + assertThat(member.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(member.getBody()).contains("STORY_READ", "DOCUMENT_READ", "CONSTRAINT_READ"); + assertThat(member.getBody()).doesNotContain("STORY_WRITE", "CONSTRAINT_WRITE", "PROJECT_DELETE"); + + // Writer member → the READ floor plus the role's CONSTRAINT_WRITE, but no unrelated writes. + ResponseEntity writer = myPermissions(projectId, ROLE_MEMBER_USER_ID, orgId); + assertThat(writer.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(writer.getBody()).contains("CONSTRAINT_WRITE", "STORY_READ"); + assertThat(writer.getBody()).doesNotContain("STORY_WRITE", "PROJECT_DELETE"); + } + + @Test + @DisplayName("me/authorization returns the caller's org role and the base-permission floor") + void me_authorization_returns_role_and_floor() { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String slug = "acme-" + suffix; + UUID orgId = createOrganizationAndReturnId(suffix, slug); + + createMember(orgId, OWNER_USER_ID, Map.of( + "userId", ADMIN_USER_ID, "email", "admin@example.com", "displayName", "Admin", "role", "ADMIN")); + createMember(orgId, OWNER_USER_ID, Map.of( + "userId", MEMBER_USER_ID, "email", "member@example.com", "displayName", "Member", "role", "MEMBER")); + + assertThat(putBasePermission(orgId, OWNER_USER_ID, "NONE").getStatusCode()).isEqualTo(HttpStatus.OK); + + ResponseEntity owner = myAuthorization(orgId, OWNER_USER_ID); + assertThat(owner.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(owner.getBody()).contains("\"orgRole\":\"OWNER\"", "\"memberBasePermission\":\"NONE\""); + + ResponseEntity admin = myAuthorization(orgId, ADMIN_USER_ID); + assertThat(admin.getBody()).contains("\"orgRole\":\"ADMIN\""); + + ResponseEntity member = myAuthorization(orgId, MEMBER_USER_ID); + assertThat(member.getBody()).contains("\"orgRole\":\"MEMBER\"", "\"memberBasePermission\":\"NONE\""); + } + + // --- request helpers -------------------------------------------------------------------------- + + private ResponseEntity putBasePermission(UUID orgId, String userId, String value) { + return client().put().uri("/api/organizations/{orgId}/base-permission", orgId) + .header("Authorization", TestJwtFactory.bearer(userId, orgId.toString(), "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("basePermission", value)) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + } + + private ResponseEntity getBasePermission(UUID orgId, String userId) { + return client().get().uri("/api/organizations/{orgId}/base-permission", orgId) + .header("Authorization", TestJwtFactory.bearer(userId, orgId.toString(), "ROLE_USER")) + .header("Api-Version", "1") + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + } + + private ResponseEntity myAuthorization(UUID orgId, String userId) { + return client().get().uri("/api/organizations/{orgId}/me/authorization", orgId) + .header("Authorization", TestJwtFactory.bearer(userId, orgId.toString(), "ROLE_USER")) + .header("Api-Version", "1") + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + } + + private ResponseEntity myPermissions(UUID projectId, String userId, UUID orgId) { + return client().get().uri("/api/projects/{projectId}/me/permissions", projectId) + .header("Authorization", TestJwtFactory.bearer(userId, orgId.toString(), "ROLE_USER")) + .header("Api-Version", "1") + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + } + + private ResponseEntity listConstraints(UUID orgId, UUID projectId, String userId) { + return client().get().uri("/api/organizations/{orgId}/projects/{projectId}/constraints", orgId, projectId) + .header("Authorization", TestJwtFactory.bearer(userId, orgId.toString(), "ROLE_USER")) + .header("Api-Version", "1") + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + } + + private void assignMember(UUID orgId, UUID projectId, String callerUserId, String memberId, String roleId) { + ResponseEntity res = client().post().uri("/api/organizations/{orgId}/projects/{projectId}/members", orgId, projectId) + .header("Authorization", TestJwtFactory.bearer(callerUserId, orgId.toString(), "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("memberId", memberId, "roleId", roleId)) + .exchange((req, r) -> ResponseEntity.status(r.getStatusCode()).body(r.bodyTo(String.class))); + assertThat(res.getStatusCode()).isEqualTo(HttpStatus.CREATED); + } + + private UUID memberId(UUID orgId, String email) { + return UUID.fromString(jdbcTemplate.queryForObject( + "SELECT id::text FROM public.members WHERE organization_id = ?::uuid AND email = ?", + String.class, orgId, email)); + } + + private UUID createOrganizationAndReturnId(String suffix, String expectedSlug) { + ResponseEntity orgRes = client().post().uri("/api/organizations") + .header("Authorization", TestJwtFactory.bearer(OWNER_USER_ID, UUID.randomUUID().toString(), "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("name", "Acme " + suffix)) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(orgRes.getStatusCode()).isEqualTo(HttpStatus.CREATED); + + return UUID.fromString(jdbcTemplate.queryForObject( + "SELECT id::text FROM public.organizations WHERE slug = ?", String.class, expectedSlug)); + } + + private UUID createProjectAndReturnId(UUID orgId, String slug, String projectName) { + ResponseEntity res = client().post().uri("/api/organizations/{orgId}/projects", orgId) + .header("Authorization", TestJwtFactory.bearer(OWNER_USER_ID, orgId.toString(), "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of( + "name", projectName, + "description", "Project description", + "programmingLanguages", List.of("Java"), + "frameworks", List.of("Spring Boot"), + "clientPlatforms", List.of("Web"), + "databases", List.of("PostgreSQL"), + "architecture", "Clean Architecture", + "domain", "Fintech")) + .exchange((req, resSpec) -> ResponseEntity.status(resSpec.getStatusCode()).body(resSpec.bodyTo(String.class))); + assertThat(res.getStatusCode()).isEqualTo(HttpStatus.CREATED); + + return UUID.fromString(jdbcTemplate.queryForObject( + "SELECT id::text FROM \"tenant_" + slug + "\".projects WHERE organization_id = ?::uuid AND name = ?", + String.class, orgId.toString(), projectName)); + } + + private String createRoleAndReturnId( + UUID orgId, UUID projectId, String userId, String name, List permissions, String schema) { + ResponseEntity response = client().post().uri("/api/organizations/{orgId}/projects/{projectId}/roles", orgId, projectId) + .header("Authorization", TestJwtFactory.bearer(userId, orgId.toString(), "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("name", name, "permissions", permissions)) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + + return jdbcTemplate.queryForObject( + "SELECT id::text FROM \"" + schema + "\".project_roles WHERE project_id = ?::uuid AND name = ?", + String.class, projectId, name); + } + + private void createMember(UUID orgId, String callerUserId, Map body) { + ResponseEntity response = client().post().uri("/api/organizations/{orgId}/members", orgId) + .header("Authorization", TestJwtFactory.bearer(callerUserId, orgId.toString(), "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + } +} From 39c0f09e4395bebe0aa82c3c2e71281968879f85 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Fri, 10 Jul 2026 08:55:52 -0500 Subject: [PATCH 08/13] docs(changelog): note the member base permission floor and endpoints --- CHANGELOG.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d86926f9..ab8e2f4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,30 @@ _Bounded-context implementation (iam, billing, workspace, discovery, gateway) in `.env.example` documents every billing/Stripe variable (provider flag, API key, per-plan Price ids, webhook secret, return-URL overrides). +### Added (Member base permission — `feature/rbac-base-permission`) + +- **GitHub-style member base permission floor** — every organization now has a `memberBasePermission` + applied to **all** project members on top of their explicit project role (roles are additive on the + floor); owners/admins bypass it entirely. Values: `NONE` (members get only their project role) or + `READ` (a read-only baseline). Default `READ`. The `READ` floor grants exactly the workspace + `*_READ` permissions members need — `MEMBER_READ`, `ROLE_READ`, `DOCUMENT_READ`, `GLOSSARY_READ`, + `CONSTRAINT_READ`, `SESSION_READ`, `STORY_READ` (integration read is excluded; integrations are + org-admin configuration). Wired into `ProjectPermissionService.hasPermission`, so it flows through + `@authz.projectPermission` and `WorkspaceModuleApi.callerHasProjectPermission` — every gated + workspace/discovery endpoint honors the floor. +- **New endpoints** (header `Api-Version: 1`): + - `GET /organizations/{orgId}/base-permission` → `{ "basePermission": "NONE"|"READ" }` (org + owner/admin). + - `PUT /organizations/{orgId}/base-permission` `{ "basePermission": "NONE"|"READ" }` → `200` + `{ "basePermission": … }` (org owner/admin). + - `GET /organizations/{orgId}/me/authorization` → + `{ "orgRole": "OWNER"|"ADMIN"|"MEMBER", "memberBasePermission": "NONE"|"READ" }` (any org member). + - `GET /projects/{projectId}/me/permissions` → `{ "permissions": ["STORY_READ", …] }` — the caller's + effective project permissions (the full catalog for owners/admins, else the base floor unioned with + their project role). Gated on active tenant membership so any member reads their own set. +- Migration `V20260710090000__organization_member_base_permission.sql` (public schema): adds + `organizations.member_base_permission VARCHAR(16) NOT NULL DEFAULT 'READ'`. + ### Added (Live session presence — `feature/discovery-presence`) - **Real-time presence for live discovery sessions** — the users currently viewing a live session are From 1ae393927bced4fed7352f2fc8b135fc2b349d93 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Fri, 10 Jul 2026 09:32:14 -0500 Subject: [PATCH 09/13] test(discovery): isolate project-role gating from the new base permission 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. --- .../rest/DiscoveryAccessControlIntegrationTest.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/test/java/com/kntro/reqsai/discovery/interfaces/rest/DiscoveryAccessControlIntegrationTest.java b/src/test/java/com/kntro/reqsai/discovery/interfaces/rest/DiscoveryAccessControlIntegrationTest.java index 57a3d4d1..3c4b7316 100644 --- a/src/test/java/com/kntro/reqsai/discovery/interfaces/rest/DiscoveryAccessControlIntegrationTest.java +++ b/src/test/java/com/kntro/reqsai/discovery/interfaces/rest/DiscoveryAccessControlIntegrationTest.java @@ -44,6 +44,10 @@ void discovery_endpoints_enforce_project_permissions() { String slug = "acme-" + suffix; String schema = "tenant_" + slug; UUID orgId = createOrganizationAndReturnId(suffix, slug); + // This suite verifies PROJECT-ROLE gating in isolation. Pin the org's member base-permission + // floor to NONE so an unassigned member has no implicit read; the READ floor itself is covered + // by BasePermissionIntegrationTest. + jdbcTemplate.update("UPDATE public.organizations SET member_base_permission = 'NONE' WHERE id = ?", orgId); createMember(orgId, Map.of( "userId", READER_USER_ID, "email", "reader@example.com", "displayName", "Reader", "role", "MEMBER")); From 381fd6ac3f91d50b9959a32630d9b529e140b4a6 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Fri, 10 Jul 2026 10:25:19 -0500 Subject: [PATCH 10/13] fix(workspace): honor the member base permission floor in project access 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. --- .../service/ProjectAccessService.java | 19 +++++++++++++++---- .../service/ProjectAccessServiceTest.java | 5 +++++ .../rest/ProjectAccessIntegrationTest.java | 6 ++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/kntro/reqsai/workspace/application/service/ProjectAccessService.java b/src/main/java/com/kntro/reqsai/workspace/application/service/ProjectAccessService.java index c34d5d64..16a414ff 100644 --- a/src/main/java/com/kntro/reqsai/workspace/application/service/ProjectAccessService.java +++ b/src/main/java/com/kntro/reqsai/workspace/application/service/ProjectAccessService.java @@ -3,6 +3,7 @@ import com.kntro.reqsai.workspace.application.port.MemberRepository; import com.kntro.reqsai.workspace.application.port.ProjectMemberRepository; import com.kntro.reqsai.workspace.domain.exception.WorkspaceExceptions; +import com.kntro.reqsai.workspace.domain.model.BasePermission; import com.kntro.reqsai.workspace.domain.model.Member; import com.kntro.reqsai.workspace.domain.model.MemberStatus; import com.kntro.reqsai.workspace.domain.model.Organization; @@ -19,8 +20,11 @@ * Resolves which projects a caller may see/access within an organization. *

* Org {@code OWNER} and {@code ADMIN} implicitly have access to all projects in their - * organization — no explicit {@link ProjectMember} row is required. A regular {@code MEMBER} can - * access only the projects where they hold an explicit {@code ProjectMember} assignment. + * organization — no explicit {@link ProjectMember} row is required. A regular {@code MEMBER}'s reach + * depends on the organization's {@link Organization#getMemberBasePermission() member base permission}: + * with a non-{@code NONE} floor ({@code READ}) every active member reaches all projects by default; + * with {@code NONE}, access is limited to the projects where they hold an explicit + * {@code ProjectMember} assignment. */ @Component @RequiredArgsConstructor @@ -44,6 +48,12 @@ public Optional> accessibleProjectIds(Organization organization, UUID .orElseThrow(() -> WorkspaceExceptions.insufficientPermissions( "access projects in organization " + organization.getId(), requestedBy)); + // A non-NONE member base-permission floor (READ) grants every active member access to ALL + // projects of the org by default; with NONE, access is limited to explicit assignments. + if (organization.getMemberBasePermission() != BasePermission.NONE) { + return Optional.empty(); + } + Set projectIds = assignments.findAllByMemberId(member.getId()).stream() .map(ProjectMember::getProjectId) .collect(Collectors.toUnmodifiableSet()); @@ -71,8 +81,9 @@ public boolean canAccessProject(Organization organization, UUID projectId, UUID } return members.findByOrganizationIdAndUserIdAndStatus( organization.getId(), requestedBy, MemberStatus.ACTIVE) - .map(member -> assignments.findAllByMemberId(member.getId()).stream() - .anyMatch(assignment -> assignment.getProjectId().equals(projectId))) + .map(member -> organization.getMemberBasePermission() != BasePermission.NONE + || assignments.findAllByMemberId(member.getId()).stream() + .anyMatch(assignment -> assignment.getProjectId().equals(projectId))) .orElse(false); } } diff --git a/src/test/java/com/kntro/reqsai/workspace/application/service/ProjectAccessServiceTest.java b/src/test/java/com/kntro/reqsai/workspace/application/service/ProjectAccessServiceTest.java index 6d0fc176..97d6f847 100644 --- a/src/test/java/com/kntro/reqsai/workspace/application/service/ProjectAccessServiceTest.java +++ b/src/test/java/com/kntro/reqsai/workspace/application/service/ProjectAccessServiceTest.java @@ -3,6 +3,7 @@ import com.kntro.reqsai.shared.domain.exception.DomainException; import com.kntro.reqsai.workspace.application.port.MemberRepository; import com.kntro.reqsai.workspace.application.port.ProjectMemberRepository; +import com.kntro.reqsai.workspace.domain.model.BasePermission; import com.kntro.reqsai.workspace.domain.model.Member; import com.kntro.reqsai.workspace.domain.model.MemberStatus; import com.kntro.reqsai.workspace.domain.model.OrgRole; @@ -76,6 +77,8 @@ void admin_unrestricted() { @DisplayName("member sees only assigned projects") void member_sees_assigned_only() { Organization org = OrganizationMother.active().build(); + // Pin the base floor to NONE so access is driven purely by explicit assignments. + org.changeMemberBasePermission(BasePermission.NONE); UUID memberUser = UUID.randomUUID(); Member m = member(org.getId(), memberUser, OrgRole.MEMBER); UUID assignedProject = UUID.randomUUID(); @@ -96,6 +99,8 @@ void member_sees_assigned_only() { @DisplayName("member is denied an unassigned project") void member_denied_unassigned() { Organization org = OrganizationMother.active().build(); + // Pin the base floor to NONE so access is driven purely by explicit assignments. + org.changeMemberBasePermission(BasePermission.NONE); UUID memberUser = UUID.randomUUID(); Member m = member(org.getId(), memberUser, OrgRole.MEMBER); UUID assignedProject = UUID.randomUUID(); diff --git a/src/test/java/com/kntro/reqsai/workspace/interfaces/rest/ProjectAccessIntegrationTest.java b/src/test/java/com/kntro/reqsai/workspace/interfaces/rest/ProjectAccessIntegrationTest.java index c624492d..43bad1d4 100644 --- a/src/test/java/com/kntro/reqsai/workspace/interfaces/rest/ProjectAccessIntegrationTest.java +++ b/src/test/java/com/kntro/reqsai/workspace/interfaces/rest/ProjectAccessIntegrationTest.java @@ -41,6 +41,9 @@ void implicit_access_for_owner_admin_and_scoped_member() { String slug = "acme-" + suffix; String schema = "tenant_" + slug; UUID orgId = createOrganizationAndReturnId(suffix, slug); + // This suite verifies assignment-based project access in isolation; pin the org's member base + // permission to NONE so members without an assignment have no implicit all-project access. + jdbcTemplate.update("UPDATE public.organizations SET member_base_permission = 'NONE' WHERE id = ?", orgId); createMember(orgId, OWNER_USER_ID, Map.of( "userId", ADMIN_USER_ID, "email", "admin@example.com", "displayName", "Admin", "role", "ADMIN")); @@ -86,6 +89,9 @@ void project_permission_enforced_on_member_management() { String slug = "acme-" + suffix; String schema = "tenant_" + slug; UUID orgId = createOrganizationAndReturnId(suffix, slug); + // This suite verifies assignment-based project access in isolation; pin the org's member base + // permission to NONE so members without an assignment have no implicit all-project access. + jdbcTemplate.update("UPDATE public.organizations SET member_base_permission = 'NONE' WHERE id = ?", orgId); // A manager (has MANAGE_MEMBERS via project role), a plain member (no manage permission), and a target. createMember(orgId, OWNER_USER_ID, Map.of( From a3cf11ca5241d875671d9cc43c8de1409453b2c3 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Fri, 10 Jul 2026 11:14:22 -0500 Subject: [PATCH 11/13] feat(workspace): embed the role name in the project members list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../ListProjectMembersQueryHandler.java | 20 +++++++-- .../result/ProjectMemberAssignment.java | 15 +++++++ .../ProjectMemberControllerImpl.java | 2 +- .../dto/response/ProjectMemberResponse.java | 1 + .../response/ProjectMemberResponseMapper.java | 10 +++++ .../rest/ProjectMemberIntegrationTest.java | 41 +++++++++++++++++++ 6 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/kntro/reqsai/workspace/application/result/ProjectMemberAssignment.java diff --git a/src/main/java/com/kntro/reqsai/workspace/application/handler/ListProjectMembersQueryHandler.java b/src/main/java/com/kntro/reqsai/workspace/application/handler/ListProjectMembersQueryHandler.java index de6437bd..7728366a 100644 --- a/src/main/java/com/kntro/reqsai/workspace/application/handler/ListProjectMembersQueryHandler.java +++ b/src/main/java/com/kntro/reqsai/workspace/application/handler/ListProjectMembersQueryHandler.java @@ -2,15 +2,20 @@ import com.kntro.reqsai.workspace.application.port.ProjectMemberRepository; import com.kntro.reqsai.workspace.application.port.ProjectRepository; +import com.kntro.reqsai.workspace.application.port.ProjectRoleRepository; import com.kntro.reqsai.workspace.application.query.ListProjectMembersQuery; +import com.kntro.reqsai.workspace.application.result.ProjectMemberAssignment; import com.kntro.reqsai.workspace.domain.exception.WorkspaceExceptions; -import com.kntro.reqsai.workspace.domain.model.ProjectMember; +import com.kntro.reqsai.workspace.domain.model.ProjectRole; import com.kntro.reqsai.workspace.domain.model.ProjectStatus; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; @Component @RequiredArgsConstructor @@ -18,12 +23,21 @@ public class ListProjectMembersQueryHandler { private final ProjectRepository projects; private final ProjectMemberRepository assignments; + private final ProjectRoleRepository roles; @Transactional(readOnly = true) - public List handle(ListProjectMembersQuery query) { + public List handle(ListProjectMembersQuery query) { projects.findByIdAndOrganizationIdAndStatus(query.projectId(), query.organizationId(), ProjectStatus.ACTIVE) .orElseThrow(() -> WorkspaceExceptions.projectNotFound(query.projectId())); - return List.copyOf(assignments.findAllByProjectId(query.projectId())); + // Resolve role names once here so the members list carries them: viewing members + // (MEMBER_READ) shouldn't also require ROLE_READ just to display each member's role. + Map roleNames = roles.findAllByProjectId(query.projectId()).stream() + .collect(Collectors.toMap(ProjectRole::getId, ProjectRole::getName)); + + return assignments.findAllByProjectId(query.projectId()).stream() + .map(assignment -> new ProjectMemberAssignment( + assignment, roleNames.get(assignment.getRoleId()))) + .toList(); } } diff --git a/src/main/java/com/kntro/reqsai/workspace/application/result/ProjectMemberAssignment.java b/src/main/java/com/kntro/reqsai/workspace/application/result/ProjectMemberAssignment.java new file mode 100644 index 00000000..93bde48d --- /dev/null +++ b/src/main/java/com/kntro/reqsai/workspace/application/result/ProjectMemberAssignment.java @@ -0,0 +1,15 @@ +package com.kntro.reqsai.workspace.application.result; + +import com.kntro.reqsai.workspace.domain.model.ProjectMember; + +/** + * A project member assignment enriched with its role's display name, so the members list is + * self-contained: a caller who can read members ({@code MEMBER_READ}) sees each member's role + * without also needing {@code ROLE_READ} to look the name up separately. + * + * @param assignment the project member assignment (member id, role id, audit fields) + * @param roleName the display name of the assignment's project role, or {@code null} if the role + * is missing (a dangling assignment) + */ +public record ProjectMemberAssignment(ProjectMember assignment, String roleName) { +} diff --git a/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/controllers/ProjectMemberControllerImpl.java b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/controllers/ProjectMemberControllerImpl.java index 7e2bcd5a..178116bb 100644 --- a/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/controllers/ProjectMemberControllerImpl.java +++ b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/controllers/ProjectMemberControllerImpl.java @@ -59,7 +59,7 @@ public ResponseEntity> inviteToProject(UUID orgId, UUID pro public ResponseEntity> listAssignments(UUID orgId, UUID projectId, Authentication authentication) { UUID requestedBy = UUID.fromString(authentication.getName()); return ResponseEntity.ok(listAssignments.handle(new ListProjectMembersQuery(orgId, projectId, requestedBy)).stream() - .map(ProjectMemberResponseMapper::toResponse).toList()); + .map(a -> ProjectMemberResponseMapper.toResponse(a.assignment(), a.roleName())).toList()); } @Override diff --git a/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/dto/response/ProjectMemberResponse.java b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/dto/response/ProjectMemberResponse.java index 46f31c7d..d54d431a 100644 --- a/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/dto/response/ProjectMemberResponse.java +++ b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/dto/response/ProjectMemberResponse.java @@ -8,6 +8,7 @@ public record ProjectMemberResponse( UUID projectId, UUID memberId, UUID roleId, + String roleName, UUID assignedBy, Instant assignedAt, Instant createdAt, diff --git a/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/mappers/response/ProjectMemberResponseMapper.java b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/mappers/response/ProjectMemberResponseMapper.java index d53c6eab..8f959363 100644 --- a/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/mappers/response/ProjectMemberResponseMapper.java +++ b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/mappers/response/ProjectMemberResponseMapper.java @@ -10,11 +10,21 @@ private ProjectMemberResponseMapper() { } public static ProjectMemberResponse toResponse(ProjectMember assignment) { + return toResponse(assignment, null); + } + + /** + * Maps an assignment, embedding the resolved role name. The list endpoint supplies it so a + * {@code MEMBER_READ} caller sees roles without {@code ROLE_READ}; single-assignment responses + * pass {@code null} and the client falls back to its own role lookup. + */ + public static ProjectMemberResponse toResponse(ProjectMember assignment, String roleName) { return new ProjectMemberResponse( assignment.getId(), assignment.getProjectId(), assignment.getMemberId(), assignment.getRoleId(), + roleName, assignment.getAssignedBy(), assignment.getAssignedAt(), assignment.getCreatedAt(), diff --git a/src/test/java/com/kntro/reqsai/workspace/interfaces/rest/ProjectMemberIntegrationTest.java b/src/test/java/com/kntro/reqsai/workspace/interfaces/rest/ProjectMemberIntegrationTest.java index 074d9dec..c551f1a4 100644 --- a/src/test/java/com/kntro/reqsai/workspace/interfaces/rest/ProjectMemberIntegrationTest.java +++ b/src/test/java/com/kntro/reqsai/workspace/interfaces/rest/ProjectMemberIntegrationTest.java @@ -79,6 +79,8 @@ void should_create_list_get_update_and_delete_project_assignments() { .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); assertThat(list.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(list.getBody()).contains(assignmentId); + // The list embeds the role's display name so viewers see roles without ROLE_READ. + assertThat(list.getBody()).contains("\"roleName\":\"Analyst\""); ResponseEntity getOne = client().get().uri("/api/organizations/{orgId}/projects/{projectId}/members/{assignmentId}", orgId, projectId, UUID.fromString(assignmentId)) @@ -139,6 +141,45 @@ void should_reject_assignment_for_non_admin_member() { assertThat(forbidden.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); } + @Test + @DisplayName("member with only MEMBER_READ can list members and sees role names without ROLE_READ") + void member_with_member_read_can_list_members() { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String slug = "acme-" + suffix; + UUID orgId = createOrganizationAndReturnId(suffix, slug); + // Pin the base floor to NONE so access rests purely on the assigned role — the exact + // scenario a member whose only project role grants MEMBER_READ lands in. + jdbcTemplate.update("UPDATE public.organizations SET member_base_permission = 'NONE' WHERE id = ?", orgId); + UUID projectId = createProjectAndReturnId(orgId, slug, "Viewer Project"); + + createMember(orgId, OWNER_USER_ID, Map.of( + "userId", MEMBER_USER_ID, + "email", "member@example.com", + "displayName", "Regular Member", + "role", "MEMBER")); + String schema = "tenant_" + slug; + String memberId = jdbcTemplate.queryForObject( + "SELECT id::text FROM public.members WHERE organization_id = ?::uuid AND email = ?", + String.class, orgId, "member@example.com"); + String roleId = createRoleAndReturnId(orgId, projectId, OWNER_USER_ID, "Viewer", List.of("MEMBER_READ"), schema); + + ResponseEntity assigned = client().post().uri("/api/organizations/{orgId}/projects/{projectId}/members", orgId, projectId) + .header("Authorization", TestJwtFactory.bearer(OWNER_USER_ID, orgId.toString(), "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("memberId", memberId, "roleId", roleId)) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(assigned.getStatusCode()).isEqualTo(HttpStatus.CREATED); + + // The member themselves — holding only MEMBER_READ via the assigned role — lists members. + ResponseEntity list = client().get().uri("/api/organizations/{orgId}/projects/{projectId}/members", orgId, projectId) + .header("Authorization", TestJwtFactory.bearer(MEMBER_USER_ID, orgId.toString(), "ROLE_USER")) + .header("Api-Version", "1") + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(list.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(list.getBody()).contains("\"roleName\":\"Viewer\""); + } + private UUID createOrganizationAndReturnId(String suffix, String expectedSlug) { ResponseEntity orgRes = client().post().uri("/api/organizations") .header("Authorization", TestJwtFactory.bearer(OWNER_USER_ID, UUID.randomUUID().toString(), "ROLE_USER")) From 9778779e926316eac7e2ee024e4b3aec8aa4cfe1 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Fri, 10 Jul 2026 12:30:36 -0500 Subject: [PATCH 12/13] feat(workspace): let member-managers read the project roles list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../ProjectRoleControllerImpl.java | 2 +- .../rest/security/WorkspaceAuthorization.java | 18 +++++++ .../rest/ProjectRoleIntegrationTest.java | 48 +++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/controllers/ProjectRoleControllerImpl.java b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/controllers/ProjectRoleControllerImpl.java index 1b7db3df..8c7dbc85 100644 --- a/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/controllers/ProjectRoleControllerImpl.java +++ b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/controllers/ProjectRoleControllerImpl.java @@ -41,7 +41,7 @@ public ResponseEntity createRole(UUID orgId, UUID projectId } @Override - @PreAuthorize("@authz.projectPermission(#orgId, #projectId, 'ROLE_READ', authentication)") + @PreAuthorize("@authz.projectAnyPermission(#orgId, #projectId, authentication, 'ROLE_READ', 'MEMBER_UPDATE_ROLE', 'MEMBER_INVITE')") public ResponseEntity> listRoles(UUID orgId, UUID projectId, Authentication authentication) { UUID requestedBy = UUID.fromString(authentication.getName()); return ResponseEntity.ok(listRoles.handle(new ListProjectRolesQuery(orgId, projectId, requestedBy)).stream() diff --git a/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/security/WorkspaceAuthorization.java b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/security/WorkspaceAuthorization.java index 76e574ef..73c6b7a4 100644 --- a/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/security/WorkspaceAuthorization.java +++ b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/security/WorkspaceAuthorization.java @@ -79,6 +79,24 @@ public boolean projectPermission( (org, userId) -> projectPermission.hasPermission(org, projectId, userId, required)); } + /** + * Caller holds any of the named {@link Permission}s on the given project. The org is + * resolved once and each permission checked against it. Used where one action is reachable + * through several grants — e.g. reading the project roles list backs both {@code ROLE_READ} + * and the member editor's {@code MEMBER_UPDATE_ROLE}/{@code MEMBER_INVITE} pickers. + */ + public boolean projectAnyPermission( + UUID orgId, UUID projectId, Authentication authentication, String... permissions) { + return onOrg(orgId, authentication, (org, userId) -> { + for (String permission : permissions) { + if (projectPermission.hasPermission(org, projectId, userId, Permission.valueOf(permission))) { + return true; + } + } + return false; + }); + } + /** * Caller holds the named {@link Permission} on the given project of the current tenant * (the JWT {@code orgId} bound by the authentication filter). For routes that carry no diff --git a/src/test/java/com/kntro/reqsai/workspace/interfaces/rest/ProjectRoleIntegrationTest.java b/src/test/java/com/kntro/reqsai/workspace/interfaces/rest/ProjectRoleIntegrationTest.java index a9ba3435..4bf432cf 100644 --- a/src/test/java/com/kntro/reqsai/workspace/interfaces/rest/ProjectRoleIntegrationTest.java +++ b/src/test/java/com/kntro/reqsai/workspace/interfaces/rest/ProjectRoleIntegrationTest.java @@ -122,6 +122,54 @@ void should_reject_project_role_creation_for_regular_member() { assertThat(forbidden.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); } + @Test + @DisplayName("member with MEMBER_UPDATE_ROLE (no ROLE_READ) can list roles for the role editor") + void member_with_member_update_role_can_list_roles() { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String slug = "acme-" + suffix; + UUID orgId = createOrganizationAndReturnId(suffix, slug); + // base=NONE so access rests purely on the assigned role — isolate the MEMBER_UPDATE_ROLE path. + jdbcTemplate.update("UPDATE public.organizations SET member_base_permission = 'NONE' WHERE id = ?", orgId); + UUID projectId = createProjectAndReturnId(orgId, slug, "Role Editor Project"); + String schema = "tenant_" + slug; + + createMember(orgId, OWNER_USER_ID, Map.of( + "userId", MEMBER_USER_ID, + "email", "member@example.com", + "displayName", "Regular Member", + "role", "MEMBER")); + String memberId = jdbcTemplate.queryForObject( + "SELECT id::text FROM public.members WHERE organization_id = ?::uuid AND email = ?", + String.class, orgId, "member@example.com"); + + // A role that grants MEMBER_UPDATE_ROLE but NOT ROLE_READ. + ResponseEntity created = client().post().uri("/api/organizations/{orgId}/projects/{projectId}/roles", orgId, projectId) + .header("Authorization", TestJwtFactory.bearer(OWNER_USER_ID, orgId.toString(), "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("name", "Member Editor", "permissions", List.of("MEMBER_READ", "MEMBER_UPDATE_ROLE"))) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED); + String roleId = jdbcTemplate.queryForObject( + "SELECT id::text FROM \"" + schema + "\".project_roles WHERE project_id = ?::uuid AND name = ?", + String.class, projectId, "Member Editor"); + + client().post().uri("/api/organizations/{orgId}/projects/{projectId}/members", orgId, projectId) + .header("Authorization", TestJwtFactory.bearer(OWNER_USER_ID, orgId.toString(), "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("memberId", memberId, "roleId", roleId)) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).build()); + + // The member — holding MEMBER_UPDATE_ROLE but not ROLE_READ — can now read the roles list. + ResponseEntity list = client().get().uri("/api/organizations/{orgId}/projects/{projectId}/roles", orgId, projectId) + .header("Authorization", TestJwtFactory.bearer(MEMBER_USER_ID, orgId.toString(), "ROLE_USER")) + .header("Api-Version", "1") + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(list.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(list.getBody()).contains("Member Editor"); + } + private UUID createOrganizationAndReturnId(String suffix, String expectedSlug) { ResponseEntity orgRes = client().post().uri("/api/organizations") .header("Authorization", TestJwtFactory.bearer(OWNER_USER_ID, UUID.randomUUID().toString(), "ROLE_USER")) From 33a01d798bd575b449041fa4ef91c0c284259562 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Fri, 10 Jul 2026 12:40:35 -0500 Subject: [PATCH 13/13] docs(changelog): note role-name embed, base-floor access and roles-list read --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab8e2f4c..0b86f586 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,17 @@ _Bounded-context implementation (iam, billing, workspace, discovery, gateway) in their project role). Gated on active tenant membership so any member reads their own set. - Migration `V20260710090000__organization_member_base_permission.sql` (public schema): adds `organizations.member_base_permission VARCHAR(16) NOT NULL DEFAULT 'READ'`. +- **Project access honors the base floor** — `canAccessProject`/`accessibleProjectIds` now grant every + active member access to all projects when the floor is non-`NONE`, falling back to explicit assignments + only under `NONE`. Previously they always required an explicit assignment, so a `READ`-floor member + could load a project's stories yet be 403'd on the project itself. +- **Members list embeds the role name** — `GET .../projects/{projectId}/members` now returns each + assignment's `roleName`, so a caller with only `MEMBER_READ` sees each member's role without also + needing `ROLE_READ` to resolve it. +- **Roles list readable by member-managers** — listing project roles now accepts `ROLE_READ` **or** + `MEMBER_UPDATE_ROLE`/`MEMBER_INVITE` (new `@authz.projectAnyPermission`, which resolves the org once + and holds when any listed permission is granted), so the member-role editor's options load without a + separate `ROLE_READ` grant. ### Added (Live session presence — `feature/discovery-presence`)