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();
}