Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
* <em>currently bound tenant</em>. 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
) {}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<Permission> 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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,42 @@

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
public class ListProjectMembersQueryHandler {

private final ProjectRepository projects;
private final ProjectMemberRepository assignments;
private final ProjectRoleRepository roles;

@Transactional(readOnly = true)
public List<ProjectMember> handle(ListProjectMembersQuery query) {
public List<ProjectMemberAssignment> 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<UUID, String> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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
) {}
Original file line number Diff line number Diff line change
@@ -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
) {}
Original file line number Diff line number Diff line change
@@ -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
) {}
Original file line number Diff line number Diff line change
@@ -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) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,8 +20,11 @@
* Resolves which projects a caller may see/access within an organization.
* <p>
* Org {@code OWNER} and {@code ADMIN} implicitly have access to <em>all</em> 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
Expand All @@ -44,6 +48,12 @@ public Optional<Set<UUID>> 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<UUID> projectIds = assignments.findAllByMemberId(member.getId()).stream()
.map(ProjectMember::getProjectId)
.collect(Collectors.toUnmodifiableSet());
Expand Down Expand Up @@ -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);
}
}
Loading
Loading