diff --git a/CHANGELOG.md b/CHANGELOG.md index d86926f9..0b86f586 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,41 @@ _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'`. +- **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`) - **Real-time presence for live discovery sessions** — the users currently viewing a live session are 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..ea27052b 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} 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 callerIsActiveMember(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/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/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/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/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/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/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/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/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/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); } } 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..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,6 +33,7 @@ class WorkspaceModuleApiImpl implements WorkspaceModuleApi { private final WorkspaceSearchRepository searchRepository; private final OrganizationRepository organizations; private final ProjectPermissionService projectPermissions; + private final OrganizationAdminAccessService orgAccess; 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 callerIsActiveMember(UUID userId) { + UUID orgId = currentTenantOrgId(); + if (orgId == null) { + return false; + } + return organizations.findById(orgId) + .map(org -> orgAccess.isMember(org, userId)) + .orElse(false); + } + @Override @Transactional(readOnly = true) public Optional findMemberDisplayName(UUID organizationId, UUID userId) { 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. + *

    + *
  • {@code NONE} — members get nothing but their explicit project role.
  • + *
  • {@code READ} — members get a read-only baseline across the workspace resources.
  • + *
+ */ +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/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/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/controllers/ProjectPermissionsControllerImpl.java b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/controllers/ProjectPermissionsControllerImpl.java new file mode 100644 index 00000000..39d62dcb --- /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.tenantMember(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/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/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/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/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/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/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/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/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..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 @@ -60,6 +60,17 @@ public boolean projectAccess(UUID orgId, UUID projectId, Authentication authenti (org, userId) -> projectAccess.canAccessProject(org, projectId, userId)); } + /** + * 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 tenantMember(Authentication authentication) { + UUID userId = callerId(authentication); + return userId != null && moduleApi.callerIsActiveMember(userId); + } + /** Caller holds the named {@link Permission} on the given project. */ public boolean projectPermission( UUID orgId, UUID projectId, String permission, Authentication authentication) { @@ -68,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/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); +} 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..e7253381 --- /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 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", + 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); +} 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'; 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")); 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/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/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); + } +} 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( 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")) 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")) 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(); }