diff --git a/contracts/openapi/work-management.yaml b/contracts/openapi/work-management.yaml index 633cd27..1d9ea05 100644 --- a/contracts/openapi/work-management.yaml +++ b/contracts/openapi/work-management.yaml @@ -23,6 +23,10 @@ tags: - name: Approvals - name: WorkItems - name: Boards + - name: Workspaces + - name: Projects + - name: Members + - name: Invitations paths: /v1/workspaces/{workspace_id}/consultations: post: @@ -727,6 +731,728 @@ paths: default: $ref: '#/components/responses/UnexpectedError' + /v1/workspaces: + post: + operationId: createWorkspace + tags: [Workspaces] + summary: Create a workspace and become its owner + description: >- + Any authenticated principal may create a workspace; the creator is + recorded as the founding owner. No existing workspace role is required, + so the baseline viewer token denotes the authenticated tier. A concurrent + duplicate slug returns 409. + security: + - bearerAuth: [] + x-required-roles: [viewer] + x-authorization-scope: authenticated + parameters: + - $ref: '#/components/parameters/CorrelationId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '../schemas/work_management.yaml#/$defs/CreateWorkspaceRequest' + responses: + '201': + description: Workspace created with a founding owner membership. + headers: + ETag: + $ref: '#/components/headers/ETag' + Location: + description: URI of the created workspace resource. + schema: + type: string + format: uri-reference + X-Correlation-ID: + $ref: '#/components/headers/CorrelationId' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '../schemas/work_management.yaml#/$defs/Workspace' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '503': + $ref: '#/components/responses/ServiceUnavailable' + default: + $ref: '#/components/responses/UnexpectedError' + + get: + operationId: listWorkspaces + tags: [Workspaces] + summary: List the workspaces the caller is an active member of + description: >- + Returns only workspaces where the authenticated principal holds an active + membership. Membership state, not a request parameter, selects the tenant + set. + security: + - bearerAuth: [] + x-required-roles: [viewer] + x-authorization-scope: authenticated + parameters: + - $ref: '#/components/parameters/CorrelationId' + responses: + '200': + description: The caller's visible workspaces. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Correlation-ID: + $ref: '#/components/headers/CorrelationId' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + type: array + items: + $ref: '../schemas/work_management.yaml#/$defs/Workspace' + '401': + $ref: '#/components/responses/Unauthorized' + '503': + $ref: '#/components/responses/ServiceUnavailable' + default: + $ref: '#/components/responses/UnexpectedError' + + /v1/workspaces/{workspace_id}: + get: + operationId: getWorkspace + tags: [Workspaces] + summary: Retrieve one workspace the caller can access + security: + - bearerAuth: [] + x-required-roles: [owner, admin, triager, approver, contributor, viewer] + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/CorrelationId' + responses: + '200': + description: The requested workspace. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Correlation-ID: + $ref: '#/components/headers/CorrelationId' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '../schemas/work_management.yaml#/$defs/Workspace' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '503': + $ref: '#/components/responses/ServiceUnavailable' + default: + $ref: '#/components/responses/UnexpectedError' + + patch: + operationId: updateWorkspace + tags: [Workspaces] + summary: Update workspace metadata + description: >- + Requires the workspace:update permission (admin or owner). An optional + If-Match strong validator must match the current workspace ETag. + security: + - bearerAuth: [] + x-required-roles: [admin, owner] + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/CorrelationId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '../schemas/work_management.yaml#/$defs/UpdateWorkspaceRequest' + responses: + '200': + description: Workspace updated. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Correlation-ID: + $ref: '#/components/headers/CorrelationId' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '../schemas/work_management.yaml#/$defs/Workspace' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '412': + $ref: '#/components/responses/PreconditionFailed' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '503': + $ref: '#/components/responses/ServiceUnavailable' + default: + $ref: '#/components/responses/UnexpectedError' + + /v1/workspaces/{workspace_id}/projects: + post: + operationId: createProject + tags: [Projects] + summary: Create a project inside a workspace + security: + - bearerAuth: [] + x-required-roles: [admin, owner] + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/CorrelationId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '../schemas/work_management.yaml#/$defs/CreateProjectRequest' + responses: + '201': + description: Project created. + headers: + ETag: + $ref: '#/components/headers/ETag' + Location: + description: URI of the created project resource. + schema: + type: string + format: uri-reference + X-Correlation-ID: + $ref: '#/components/headers/CorrelationId' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '../schemas/work_management.yaml#/$defs/Project' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '503': + $ref: '#/components/responses/ServiceUnavailable' + default: + $ref: '#/components/responses/UnexpectedError' + + get: + operationId: listProjects + tags: [Projects] + summary: List projects in a workspace + security: + - bearerAuth: [] + x-required-roles: [owner, admin, triager, approver, contributor, viewer] + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/CorrelationId' + responses: + '200': + description: Projects in the workspace. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Correlation-ID: + $ref: '#/components/headers/CorrelationId' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + type: array + items: + $ref: '../schemas/work_management.yaml#/$defs/Project' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '503': + $ref: '#/components/responses/ServiceUnavailable' + default: + $ref: '#/components/responses/UnexpectedError' + + /v1/workspaces/{workspace_id}/projects/{project_id}: + get: + operationId: getProject + tags: [Projects] + summary: Retrieve one project within a workspace + security: + - bearerAuth: [] + x-required-roles: [owner, admin, triager, approver, contributor, viewer] + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/CorrelationId' + responses: + '200': + description: The requested project. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Correlation-ID: + $ref: '#/components/headers/CorrelationId' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '../schemas/work_management.yaml#/$defs/Project' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '503': + $ref: '#/components/responses/ServiceUnavailable' + default: + $ref: '#/components/responses/UnexpectedError' + + patch: + operationId: updateProject + tags: [Projects] + summary: Update project metadata + security: + - bearerAuth: [] + x-required-roles: [admin, owner] + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/IfMatch' + - $ref: '#/components/parameters/CorrelationId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '../schemas/work_management.yaml#/$defs/UpdateProjectRequest' + responses: + '200': + description: Project updated. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Correlation-ID: + $ref: '#/components/headers/CorrelationId' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '../schemas/work_management.yaml#/$defs/Project' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '412': + $ref: '#/components/responses/PreconditionFailed' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '503': + $ref: '#/components/responses/ServiceUnavailable' + default: + $ref: '#/components/responses/UnexpectedError' + + /v1/workspaces/{workspace_id}/members: + get: + operationId: listWorkspaceMembers + tags: [Members] + summary: List workspace memberships + security: + - bearerAuth: [] + x-required-roles: [owner, admin, triager, approver, contributor, viewer] + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/CorrelationId' + responses: + '200': + description: Memberships in the workspace. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Correlation-ID: + $ref: '#/components/headers/CorrelationId' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + type: array + items: + $ref: '../schemas/work_management.yaml#/$defs/WorkspaceMember' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '503': + $ref: '#/components/responses/ServiceUnavailable' + default: + $ref: '#/components/responses/UnexpectedError' + + /v1/workspaces/{workspace_id}/members/{membership_id}: + patch: + operationId: changeMemberRole + tags: [Members] + summary: Change a member's workspace role + description: >- + An admin may assign any role except owner and may not modify an owner + membership; only an owner may create another owner. Demoting the last + owner returns 409. + security: + - bearerAuth: [] + x-required-roles: [admin, owner] + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/MembershipId' + - $ref: '#/components/parameters/CorrelationId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '../schemas/work_management.yaml#/$defs/ChangeMemberRoleRequest' + responses: + '200': + description: Membership role updated. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Correlation-ID: + $ref: '#/components/headers/CorrelationId' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '../schemas/work_management.yaml#/$defs/WorkspaceMember' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '503': + $ref: '#/components/responses/ServiceUnavailable' + default: + $ref: '#/components/responses/UnexpectedError' + + delete: + operationId: revokeMember + tags: [Members] + summary: Revoke a workspace membership + description: >- + Revokes an active membership. Revoking the last owner returns 409 and + membership state is retained for the audit trail. + security: + - bearerAuth: [] + x-required-roles: [admin, owner] + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/MembershipId' + - $ref: '#/components/parameters/CorrelationId' + responses: + '204': + description: Membership revoked. + headers: + X-Correlation-ID: + $ref: '#/components/headers/CorrelationId' + X-Request-ID: + $ref: '#/components/headers/RequestId' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '503': + $ref: '#/components/responses/ServiceUnavailable' + default: + $ref: '#/components/responses/UnexpectedError' + + /v1/workspaces/{workspace_id}/members/{membership_id}/transfer-ownership: + post: + operationId: transferOwnership + tags: [Members] + summary: Transfer workspace ownership to another member + description: >- + Only an owner may transfer ownership. The target becomes owner and the + acting owner is stepped down to admin, so the workspace always retains at + least one owner. + security: + - bearerAuth: [] + x-required-roles: [owner] + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/MembershipId' + - $ref: '#/components/parameters/CorrelationId' + responses: + '200': + description: Ownership transferred. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Correlation-ID: + $ref: '#/components/headers/CorrelationId' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + type: object + additionalProperties: false + required: [new_owner, previous_owner] + properties: + new_owner: + $ref: '../schemas/work_management.yaml#/$defs/WorkspaceMember' + previous_owner: + $ref: '../schemas/work_management.yaml#/$defs/WorkspaceMember' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '503': + $ref: '#/components/responses/ServiceUnavailable' + default: + $ref: '#/components/responses/UnexpectedError' + + /v1/workspaces/{workspace_id}/invitations: + post: + operationId: createInvitation + tags: [Invitations] + summary: Invite an identity to join a workspace + description: >- + An admin or owner invites one verified identity with an assignable role. + A duplicate active member or pending invitation returns 409. The one-time + token is returned only in this response. + security: + - bearerAuth: [] + x-required-roles: [admin, owner] + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/CorrelationId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '../schemas/work_management.yaml#/$defs/CreateInvitationRequest' + responses: + '201': + description: Invitation created; the token is disclosed once. + headers: + ETag: + $ref: '#/components/headers/ETag' + Location: + description: URI of the created invitation resource. + schema: + type: string + format: uri-reference + X-Correlation-ID: + $ref: '#/components/headers/CorrelationId' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '../schemas/work_management.yaml#/$defs/CreatedInvitation' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '503': + $ref: '#/components/responses/ServiceUnavailable' + default: + $ref: '#/components/responses/UnexpectedError' + + get: + operationId: listInvitations + tags: [Invitations] + summary: List workspace invitations + security: + - bearerAuth: [] + x-required-roles: [owner, admin, triager, approver, contributor, viewer] + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/CorrelationId' + responses: + '200': + description: Invitations in the workspace. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Correlation-ID: + $ref: '#/components/headers/CorrelationId' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + type: array + items: + $ref: '../schemas/work_management.yaml#/$defs/WorkspaceInvitation' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '503': + $ref: '#/components/responses/ServiceUnavailable' + default: + $ref: '#/components/responses/UnexpectedError' + + /v1/workspaces/{workspace_id}/invitations/{invitation_id}/revoke: + post: + operationId: revokeInvitation + tags: [Invitations] + summary: Revoke a pending invitation + security: + - bearerAuth: [] + x-required-roles: [admin, owner] + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/InvitationId' + - $ref: '#/components/parameters/CorrelationId' + responses: + '200': + description: Invitation revoked. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Correlation-ID: + $ref: '#/components/headers/CorrelationId' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '../schemas/work_management.yaml#/$defs/WorkspaceInvitation' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '503': + $ref: '#/components/responses/ServiceUnavailable' + default: + $ref: '#/components/responses/UnexpectedError' + + /v1/workspaces/{workspace_id}/invitations/{invitation_id}/accept: + post: + operationId: acceptInvitation + tags: [Invitations] + summary: Accept an invitation to join a workspace + description: >- + Identity-bound authorization: the authenticated principal must be the + invited identity and present the exact one-time token. No existing + workspace role is required, so the baseline viewer token denotes the + authenticated tier. A wrong identity, workspace, or token, and a token + that is already consumed or withdrawn, return 404; a still-pending but + expired invitation returns 409. + security: + - bearerAuth: [] + x-required-roles: [viewer] + x-authorization-scope: authenticated + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - $ref: '#/components/parameters/InvitationId' + - $ref: '#/components/parameters/CorrelationId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '../schemas/work_management.yaml#/$defs/AcceptInvitationRequest' + responses: + '201': + description: Invitation accepted and membership created. + headers: + ETag: + $ref: '#/components/headers/ETag' + X-Correlation-ID: + $ref: '#/components/headers/CorrelationId' + X-Request-ID: + $ref: '#/components/headers/RequestId' + content: + application/json: + schema: + $ref: '../schemas/work_management.yaml#/$defs/WorkspaceMember' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/UnprocessableEntity' + '503': + $ref: '#/components/responses/ServiceUnavailable' + default: + $ref: '#/components/responses/UnexpectedError' + components: securitySchemes: bearerAuth: @@ -811,6 +1537,24 @@ components: required: true schema: $ref: '../schemas/work_management.yaml#/$defs/UUID' + ProjectId: + name: project_id + in: path + required: true + schema: + $ref: '../schemas/work_management.yaml#/$defs/UUID' + MembershipId: + name: membership_id + in: path + required: true + schema: + $ref: '../schemas/work_management.yaml#/$defs/UUID' + InvitationId: + name: invitation_id + in: path + required: true + schema: + $ref: '../schemas/work_management.yaml#/$defs/UUID' ProposalId: name: proposal_id in: path diff --git a/contracts/schemas/work_management.yaml b/contracts/schemas/work_management.yaml index 29a8405..a0a6875 100644 --- a/contracts/schemas/work_management.yaml +++ b/contracts/schemas/work_management.yaml @@ -845,6 +845,199 @@ $defs: page_info: $ref: '#/$defs/PageInfo' + MembershipState: + type: string + description: >- + Lifecycle of a workspace membership binding. A revoked row is retained for + the audit trail and does not block re-invitation. + enum: [active, revoked] + + InvitationState: + type: string + description: Lifecycle of an identity-bound workspace invitation. + enum: [pending, accepted, revoked, expired] + + WorkspaceMember: + type: object + description: >- + The tenant-scoped authorization binding for one verified identity. Issued by + Issue #9 membership administration. + required: [membership_id, workspace_id, user_id, role, state, created_at, updated_at] + additionalProperties: false + properties: + membership_id: + $ref: '#/$defs/UUID' + workspace_id: + $ref: '#/$defs/UUID' + user_id: + $ref: '#/$defs/UUID' + role: + $ref: '#/$defs/Role' + state: + $ref: '#/$defs/MembershipState' + created_at: + $ref: '#/$defs/RFC3339DateTime' + updated_at: + $ref: '#/$defs/RFC3339DateTime' + + WorkspaceInvitation: + type: object + description: >- + A pending, identity-bound offer to join a workspace. The bearer token is + never persisted; only its digest is stored. + required: [invitation_id, workspace_id, invited_user_id, role, state, expires_at, created_at] + additionalProperties: false + properties: + invitation_id: + $ref: '#/$defs/UUID' + workspace_id: + $ref: '#/$defs/UUID' + invited_user_id: + $ref: '#/$defs/UUID' + invited_email: + anyOf: + - type: string + minLength: 3 + maxLength: 320 + - type: 'null' + role: + $ref: '#/$defs/Role' + state: + $ref: '#/$defs/InvitationState' + expires_at: + $ref: '#/$defs/RFC3339DateTime' + created_at: + $ref: '#/$defs/RFC3339DateTime' + accepted_at: + anyOf: + - $ref: '#/$defs/RFC3339DateTime' + - type: 'null' + revoked_at: + anyOf: + - $ref: '#/$defs/RFC3339DateTime' + - type: 'null' + + CreatedInvitation: + type: object + description: >- + Response for a created invitation: the persisted invitation plus the + one-time bearer token the administrator must deliver out of band. The token + is not retrievable again. + required: [invitation, token] + additionalProperties: false + properties: + invitation: + $ref: '#/$defs/WorkspaceInvitation' + token: + type: string + minLength: 16 + maxLength: 512 + description: One-time invitation token; stored only as a SHA-256 digest. + + CreateWorkspaceRequest: + type: object + description: Request DTO for POST /v1/workspaces. + additionalProperties: false + required: [name, slug] + properties: + name: + type: string + minLength: 3 + maxLength: 120 + slug: + type: string + pattern: ^[a-z0-9]+(?:-[a-z0-9]+)*$ + maxLength: 80 + + UpdateWorkspaceRequest: + type: object + description: Request DTO for PATCH /v1/workspaces/{workspace_id}. + additionalProperties: false + required: [name] + properties: + name: + type: string + minLength: 3 + maxLength: 120 + + CreateProjectRequest: + type: object + description: Request DTO for POST /v1/workspaces/{workspace_id}/projects. + additionalProperties: false + required: [key, name] + properties: + key: + type: string + pattern: ^[A-Z][A-Z0-9]{1,9}$ + name: + type: string + minLength: 3 + maxLength: 120 + description: + anyOf: + - type: string + minLength: 1 + - type: 'null' + + UpdateProjectRequest: + type: object + description: Request DTO for PATCH /v1/workspaces/{workspace_id}/projects/{project_id}. + additionalProperties: false + minProperties: 1 + properties: + name: + type: string + minLength: 3 + maxLength: 120 + description: + anyOf: + - type: string + minLength: 1 + - type: 'null' + + ChangeMemberRoleRequest: + type: object + description: Request DTO for PATCH /v1/workspaces/{workspace_id}/members/{membership_id}. + additionalProperties: false + required: [role] + properties: + role: + $ref: '#/$defs/Role' + + CreateInvitationRequest: + type: object + description: Request DTO for POST /v1/workspaces/{workspace_id}/invitations. + additionalProperties: false + required: [invited_user_id, role] + properties: + invited_user_id: + $ref: '#/$defs/UUID' + role: + $ref: '#/$defs/Role' + invited_email: + anyOf: + - type: string + minLength: 3 + maxLength: 320 + - type: 'null' + expires_in_seconds: + type: integer + minimum: 300 + maximum: 2592000 + default: 1209600 + description: Invitation lifetime; defaults to fourteen days. + + AcceptInvitationRequest: + type: object + description: Request DTO for POST /v1/workspaces/{workspace_id}/invitations/{invitation_id}/accept. + additionalProperties: false + required: [token] + properties: + token: + type: string + minLength: 16 + maxLength: 512 + ErrorResponse: type: object description: >- diff --git a/docs/architecture/work-management-api.md b/docs/architecture/work-management-api.md index 50ef198..9c5790d 100644 --- a/docs/architecture/work-management-api.md +++ b/docs/architecture/work-management-api.md @@ -74,6 +74,10 @@ section 3.1) and for contract tests. | `MoveBoardCardRequest` | `PATCH /v1/workspaces/{workspace_id}/boards/{board_id}/cards/{card_id}` | | `ConsultationPage`, `WorkItemPage`, `BoardCardPage` | list responses | | `WorkItem`, `Board` | corresponding detail GET responses | +| `CreateWorkspaceRequest`, `UpdateWorkspaceRequest` | workspace administration (Issue #9) | +| `CreateProjectRequest`, `UpdateProjectRequest` | project administration (Issue #9) | +| `WorkspaceMember`, `ChangeMemberRoleRequest` | membership administration (Issue #9) | +| `WorkspaceInvitation`, `CreatedInvitation`, `CreateInvitationRequest`, `AcceptInvitationRequest` | invitation administration (Issue #9) | Entity DTOs (`Consultation`, `Proposal`, `Approval`, `WorkItem`, `BoardCard`) remain the canonical shapes shared by all three clients. @@ -212,11 +216,42 @@ descriptive names map as follows: | Admin | `admin` / `owner` | Roles must be enforced server-side; clients use them only to show or hide UI. -Issue #11 declares these requirements but implements neither authentication -(Issue #10) nor RBAC (Issue #9). A +Issue #11 declares these requirements, Issue #10 supplies the verified principal, +and Issue #9 supplies the RBAC enforcement. A `workspace_id` in a path is a locator, never an authority ([tenant isolation](../security/tenant-isolation.md)). +### 8.1 Workspace administration and RBAC (Issue #9) + +Issue #9 adds the workspace, project, membership and invitation operations and +the server-side authorization that enforces `x-required-roles`. The action matrix +lives in `eventflow_work_management.authorization.policy` and is cross-checked +against this contract by `tests/test_rbac_policy_unit.py`, so the declared roles +and the enforced policy cannot drift. + +Three operations are **authentication-scoped** rather than workspace-role-scoped: + +| Operation | Why no workspace role applies | +|---|---| +| `createWorkspace` | The caller is not yet a member of any workspace. | +| `listWorkspaces` | The response set is selected by membership, not by a runtime role. | +| `acceptInvitation` | Authorization is identity-bound to the invited identity. | + +They declare `x-required-roles: [viewer]` — the baseline authenticated tier, so +the least-privilege lower bound — plus an explicit +`x-authorization-scope: authenticated` marker. Enforcement additionally requires +the verified principal (and, for acceptance, the exact token digest and matching +`invited_user_id`). The authoritative roles are the six values `owner`, `admin`, +`triager`, `approver`, `contributor`, and `viewer`; `viewer` is read-only, +`owner` has every action, and only an owner may grant or revoke the owner seat. + +Tenant isolation is enforced by resolving membership before any action: a missing +workspace, a missing active membership, or a foreign resource all return `404`, +while a visible workspace with an insufficient role returns `403` (TI-02/TI-03). +Membership invariants that span rows (at least one active owner) are serialized +with a workspace row lock (`SELECT ... FOR UPDATE`). Invitations store only a +SHA-256 digest of the one-time token and are bound to one identity and workspace. + Authorization defaults to deny. A workspace role is evaluated only after workspace membership is established; it does not grant access to another workspace. Where a request includes `project_id` or a board/card relationship, diff --git a/docs/security/tenant-isolation.md b/docs/security/tenant-isolation.md index ff53584..fd33e76 100644 --- a/docs/security/tenant-isolation.md +++ b/docs/security/tenant-isolation.md @@ -8,7 +8,10 @@ between the different identities involved in a request. It is subordinate to [ADR-007](../adr/ADR-007-multi-client-monorepo-layout-and-contracts.md), and it constrains [Issue #47](https://github.com/jacksonsr451/EventFlow/issues/47). -Status: Specified (Phase 0). No enforcement code exists yet. +Status: Partially implemented. Issue #9 enforces the workspace membership and RBAC +invariants (TI-02, TI-03, TI-06, TI-07 and the synchronous parts of TI-01/TI-04) +in `services/work-management`. The asynchronous invariants (TI-08, TI-09, TI-10) +remain specified and are owned by their respective issues. ## Identity Separation @@ -116,8 +119,8 @@ today. | TI-03 | Cross-tenant read returns `404`, never `403` | Issue #47 tests | Issue #47 | | TI-04 | Every query, projection, and export includes the tenant predicate | Issue #47 / #31 tests | Issue #12 | | TI-05 | Cache keys that hold tenant data include the workspace id | Issue #12 tests | Issue #12 | -| TI-06 | Only an owner/admin can change roles; roles do not cross workspaces | Issue #9 tests | Issue #9 | -| TI-07 | Absence of a grant denies access | Issue #9 tests | Issue #9 | +| TI-06 | Only an owner/admin can change roles; roles do not cross workspaces | `services/work-management/tests/test_workspace_management_integration.py` | Issue #9 | +| TI-07 | Absence of a grant denies access | `services/work-management/tests/test_rbac_policy_unit.py`, `test_workspace_management_integration.py` | Issue #9 | | TI-08 | A cross-tenant attempt emits `tenant.cross_access_attempt` | Issue #47 tests | Issue #47 | | TI-09 | A job without tenant context fails closed; a payload tenant is ignored | Issue #46 tests | Issue #46 | | TI-10 | A service principal cannot act on a workspace outside its binding | Issue #27 / #47 tests | Issue #27 | diff --git a/services/work-management/src/eventflow_work_management/api/app.py b/services/work-management/src/eventflow_work_management/api/app.py index 369de82..93bbfcf 100644 --- a/services/work-management/src/eventflow_work_management/api/app.py +++ b/services/work-management/src/eventflow_work_management/api/app.py @@ -18,8 +18,10 @@ authentication_dependency_error_handler, authentication_error_handler, oidc_error_handler, + problem_error_handler, problem_response, ) +from eventflow_work_management.api.workspace_routes import router as workspace_router from eventflow_work_management.config import database_url from eventflow_work_management.db.engine import create_engine_from_url, session_factory from eventflow_work_management.security.config import AuthSettings @@ -27,6 +29,7 @@ from eventflow_work_management.security.errors import ( AuthenticationDependencyError, AuthenticationError, + ProblemError, ) from eventflow_work_management.security.logging import install_access_log_redaction from eventflow_work_management.security.oidc import OIDCClient, OIDCError @@ -80,8 +83,15 @@ async def lifespan(application: FastAPI): CORSMiddleware, allow_origins=list(auth_settings.allowed_origins), allow_credentials=True, - allow_methods=["GET", "POST", "DELETE", "OPTIONS"], - allow_headers=["Authorization", "Content-Type", "X-CSRF-Token", "X-Correlation-ID"], + allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"], + allow_headers=[ + "Authorization", + "Content-Type", + "X-CSRF-Token", + "X-Correlation-ID", + "If-Match", + "Idempotency-Key", + ], expose_headers=["X-Correlation-ID"], max_age=600, ) @@ -107,6 +117,7 @@ async def security_headers(request: Request, call_next): AuthenticationDependencyError, authentication_dependency_error_handler ) app.add_exception_handler(OIDCError, oidc_error_handler) + app.add_exception_handler(ProblemError, problem_error_handler) @app.exception_handler(RequestValidationError) async def request_validation_error(request: Request, exc: RequestValidationError): @@ -170,5 +181,6 @@ def ready(): return {"status": "ready"} app.include_router(authentication_router) + app.include_router(workspace_router) return app diff --git a/services/work-management/src/eventflow_work_management/api/errors.py b/services/work-management/src/eventflow_work_management/api/errors.py index e0f36a9..e585d99 100644 --- a/services/work-management/src/eventflow_work_management/api/errors.py +++ b/services/work-management/src/eventflow_work_management/api/errors.py @@ -11,6 +11,7 @@ from eventflow_work_management.security.errors import ( AuthenticationDependencyError, AuthenticationError, + ProblemError, ) from eventflow_work_management.security.oidc import OIDCError, OIDCProviderUnavailable @@ -74,6 +75,24 @@ async def authentication_error_handler(request: Request, exc: AuthenticationErro ) +async def problem_error_handler(request: Request, exc: ProblemError): + """Map a sanitized domain problem to RFC 9457 without leaking internals.""" + title = exc.title or { + 400: "Invalid request", + 403: "Forbidden", + 404: "Not found", + 409: "Conflict", + 412: "Precondition failed", + }.get(exc.status_code, "Request failed") + return problem_response( + request, + status_code=exc.status_code, + title=title, + detail=exc.detail, + code=exc.code, + ) + + async def authentication_dependency_error_handler( request: Request, exc: AuthenticationDependencyError ): diff --git a/services/work-management/src/eventflow_work_management/api/workspace_routes.py b/services/work-management/src/eventflow_work_management/api/workspace_routes.py new file mode 100644 index 0000000..4947d32 --- /dev/null +++ b/services/work-management/src/eventflow_work_management/api/workspace_routes.py @@ -0,0 +1,513 @@ +"""Authenticated workspace, project, membership and invitation routes (Issue #9). + +The tenant is always derived from the verified principal and the path locator, +never from a request body. Every mutating route is registered with +``require_csrf`` so cookie-authenticated writes need Origin + CSRF, while +bearer-authenticated callers are unaffected. +""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from fastapi import APIRouter, Depends, Request, Response +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from eventflow_work_management.api.dependencies import ( + authenticated_principal, + correlation_id, + database_session, + require_csrf, +) +from eventflow_work_management.authorization.policy import Action +from eventflow_work_management.authorization.service import AuthorizationService +from eventflow_work_management.hashing import fingerprint +from eventflow_work_management.models.membership import WorkspaceInvitation +from eventflow_work_management.models.workspace import Project, Workspace +from eventflow_work_management.security.errors import ProblemError +from eventflow_work_management.security.principal import AuthenticatedPrincipal +from eventflow_work_management.services.workspace_management import ( + DEFAULT_INVITATION_TTL_SECONDS, + InvitationService, + MembershipService, + ProjectService, + WorkspaceService, +) + +router = APIRouter(tags=["Workspaces"]) + +SLUG_PATTERN = r"^[a-z0-9]+(?:-[a-z0-9]+)*$" +KEY_PATTERN = r"^[A-Z][A-Z0-9]{1,9}$" + +ALL_ROLES_NOTE = ( + "Requires an authenticated principal only; no existing workspace role is " + "required. The baseline `viewer` token denotes that authenticated tier." +) + + +class CreateWorkspaceRequest(BaseModel): + name: str = Field(min_length=3, max_length=120) + slug: str = Field(max_length=80, pattern=SLUG_PATTERN) + + +class UpdateWorkspaceRequest(BaseModel): + name: str = Field(min_length=3, max_length=120) + + +class CreateProjectRequest(BaseModel): + key: str = Field(pattern=KEY_PATTERN) + name: str = Field(min_length=3, max_length=120) + description: str | None = Field(default=None, min_length=1) + + +class UpdateProjectRequest(BaseModel): + name: str | None = Field(default=None, min_length=3, max_length=120) + description: str | None = Field(default=None, min_length=1) + + +class ChangeMemberRoleRequest(BaseModel): + role: str + + +class CreateInvitationRequest(BaseModel): + invited_user_id: UUID + role: str + invited_email: str | None = Field(default=None, min_length=3, max_length=320) + expires_in_seconds: int = Field( + default=DEFAULT_INVITATION_TTL_SECONDS, ge=300, le=30 * 24 * 60 * 60 + ) + + +class AcceptInvitationRequest(BaseModel): + token: str = Field(min_length=16, max_length=512) + + +def _etag(*parts: object) -> str: + return f'"{fingerprint(list(parts))}"' + + +def _workspace_etag(workspace: Workspace) -> str: + return _etag("workspace", str(workspace.id), workspace.updated_at.isoformat()) + + +def _project_etag(project: Project) -> str: + return _etag("project", str(project.id), project.updated_at.isoformat()) + + +def _member_etag(membership) -> str: # noqa: ANN001 - ORM entity + return _etag("membership", str(membership.id), membership.updated_at.isoformat()) + + +def _invitation_etag(invitation: WorkspaceInvitation) -> str: + return _etag("invitation", str(invitation.id), invitation.updated_at.isoformat()) + + +def _require_if_match(request: Request, current: str) -> None: + supplied = request.headers.get("if-match") + if supplied is not None and supplied != current: + raise ProblemError( + status_code=412, + code="PRECONDITION_FAILED", + detail="The supplied If-Match validator is stale.", + title="Precondition failed", + ) + + +def _workspace_payload(workspace: Workspace) -> dict: + return { + "workspace_id": workspace.id, + "name": workspace.name, + "slug": workspace.slug, + "created_at": workspace.created_at, + "updated_at": workspace.updated_at, + } + + +def _project_payload(project: Project) -> dict: + return { + "project_id": project.id, + "workspace_id": project.workspace_id, + "key": project.key, + "name": project.name, + "description": project.description, + "created_at": project.created_at, + "updated_at": project.updated_at, + } + + +def _member_payload(membership) -> dict: # noqa: ANN001 - ORM entity + return { + "membership_id": membership.id, + "workspace_id": membership.workspace_id, + "user_id": membership.user_id, + "role": membership.role, + "state": membership.state, + "created_at": membership.created_at, + "updated_at": membership.updated_at, + } + + +def _invitation_payload(invitation: WorkspaceInvitation) -> dict: + return { + "invitation_id": invitation.id, + "workspace_id": invitation.workspace_id, + "invited_user_id": invitation.invited_user_id, + "invited_email": invitation.invited_email, + "role": invitation.role, + "state": invitation.state, + "expires_at": invitation.expires_at, + "created_at": invitation.created_at, + "accepted_at": invitation.accepted_at, + "revoked_at": invitation.revoked_at, + } + + +def _authorize( + session: Session, + principal: AuthenticatedPrincipal, + workspace_id: UUID, + action: Action, +): + return AuthorizationService(session).authorize(principal, workspace_id, action) + + +# --------------------------------------------------------------------------- +# Workspaces +# --------------------------------------------------------------------------- + + +@router.post("/v1/workspaces", status_code=201) +def create_workspace( + payload: CreateWorkspaceRequest, + request: Request, + response: Response, + principal: AuthenticatedPrincipal = Depends(require_csrf), + session: Session = Depends(database_session), + request_correlation_id: UUID | None = Depends(correlation_id), +): + workspace = WorkspaceService(session).create( + principal, + name=payload.name, + slug=payload.slug, + correlation_id=request_correlation_id, + ) + response.headers["ETag"] = _workspace_etag(workspace) + response.headers["Location"] = f"/v1/workspaces/{workspace.id}" + return _workspace_payload(workspace) + + +@router.get("/v1/workspaces") +def list_workspaces( + response: Response, + principal: AuthenticatedPrincipal = Depends(authenticated_principal), + session: Session = Depends(database_session), +): + workspaces = WorkspaceService(session).list_for(principal) + response.headers["ETag"] = _etag( + "workspaces", + [str(workspace.id) + workspace.updated_at.isoformat() for workspace in workspaces], + ) + return [_workspace_payload(workspace) for workspace in workspaces] + + +@router.get("/v1/workspaces/{workspace_id}") +def get_workspace( + workspace_id: UUID, + response: Response, + principal: AuthenticatedPrincipal = Depends(authenticated_principal), + session: Session = Depends(database_session), +): + context = _authorize(session, principal, workspace_id, Action.WORKSPACE_READ) + response.headers["ETag"] = _workspace_etag(context.workspace) + return _workspace_payload(context.workspace) + + +@router.patch("/v1/workspaces/{workspace_id}") +def update_workspace( + workspace_id: UUID, + payload: UpdateWorkspaceRequest, + request: Request, + response: Response, + principal: AuthenticatedPrincipal = Depends(require_csrf), + session: Session = Depends(database_session), + request_correlation_id: UUID | None = Depends(correlation_id), +): + context = _authorize(session, principal, workspace_id, Action.WORKSPACE_UPDATE) + _require_if_match(request, _workspace_etag(context.workspace)) + workspace = WorkspaceService(session).update( + context, name=payload.name, correlation_id=request_correlation_id + ) + response.headers["ETag"] = _workspace_etag(workspace) + return _workspace_payload(workspace) + + +# --------------------------------------------------------------------------- +# Projects +# --------------------------------------------------------------------------- + + +@router.post("/v1/workspaces/{workspace_id}/projects", status_code=201) +def create_project( + workspace_id: UUID, + payload: CreateProjectRequest, + response: Response, + principal: AuthenticatedPrincipal = Depends(require_csrf), + session: Session = Depends(database_session), + request_correlation_id: UUID | None = Depends(correlation_id), +): + context = _authorize(session, principal, workspace_id, Action.PROJECT_CREATE) + project = ProjectService(session).create( + context, + key=payload.key, + name=payload.name, + description=payload.description, + correlation_id=request_correlation_id, + ) + response.headers["ETag"] = _project_etag(project) + response.headers["Location"] = ( + f"/v1/workspaces/{workspace_id}/projects/{project.id}" + ) + return _project_payload(project) + + +@router.get("/v1/workspaces/{workspace_id}/projects") +def list_projects( + workspace_id: UUID, + response: Response, + principal: AuthenticatedPrincipal = Depends(authenticated_principal), + session: Session = Depends(database_session), +): + context = _authorize(session, principal, workspace_id, Action.PROJECT_READ) + projects = ProjectService(session).list(context) + response.headers["ETag"] = _etag( + "projects", + [str(project.id) + project.updated_at.isoformat() for project in projects], + ) + return [_project_payload(project) for project in projects] + + +@router.get("/v1/workspaces/{workspace_id}/projects/{project_id}") +def get_project( + workspace_id: UUID, + project_id: UUID, + response: Response, + principal: AuthenticatedPrincipal = Depends(authenticated_principal), + session: Session = Depends(database_session), +): + context = _authorize(session, principal, workspace_id, Action.PROJECT_READ) + project = ProjectService(session).get(context, project_id) + response.headers["ETag"] = _project_etag(project) + return _project_payload(project) + + +@router.patch("/v1/workspaces/{workspace_id}/projects/{project_id}") +def update_project( + workspace_id: UUID, + project_id: UUID, + payload: UpdateProjectRequest, + request: Request, + response: Response, + principal: AuthenticatedPrincipal = Depends(require_csrf), + session: Session = Depends(database_session), + request_correlation_id: UUID | None = Depends(correlation_id), +): + context = _authorize(session, principal, workspace_id, Action.PROJECT_UPDATE) + current = ProjectService(session).get(context, project_id) + _require_if_match(request, _project_etag(current)) + project = ProjectService(session).update( + context, + project_id, + name=payload.name, + description=payload.description, + update_description="description" in payload.model_fields_set, + correlation_id=request_correlation_id, + ) + response.headers["ETag"] = _project_etag(project) + return _project_payload(project) + + +# --------------------------------------------------------------------------- +# Members and roles +# --------------------------------------------------------------------------- + + +@router.get("/v1/workspaces/{workspace_id}/members") +def list_members( + workspace_id: UUID, + response: Response, + principal: AuthenticatedPrincipal = Depends(authenticated_principal), + session: Session = Depends(database_session), +): + context = _authorize(session, principal, workspace_id, Action.MEMBERSHIP_READ) + members = MembershipService(session).list(context) + response.headers["ETag"] = _etag( + "members", + [ + str(member.id) + member.updated_at.isoformat() + member.role + for member in members + ], + ) + return [_member_payload(member) for member in members] + + +@router.patch("/v1/workspaces/{workspace_id}/members/{membership_id}") +def change_member_role( + workspace_id: UUID, + membership_id: UUID, + payload: ChangeMemberRoleRequest, + request: Request, + response: Response, + principal: AuthenticatedPrincipal = Depends(require_csrf), + session: Session = Depends(database_session), + request_correlation_id: UUID | None = Depends(correlation_id), +): + context = _authorize( + session, principal, workspace_id, Action.MEMBERSHIP_UPDATE_ROLE + ) + membership = MembershipService(session).change_role( + context, + membership_id, + role=payload.role, + correlation_id=request_correlation_id, + ) + response.headers["ETag"] = _member_etag(membership) + return _member_payload(membership) + + +@router.delete("/v1/workspaces/{workspace_id}/members/{membership_id}", status_code=204) +def revoke_member( + workspace_id: UUID, + membership_id: UUID, + response: Response, + reason: str = "revoked_by_administrator", + principal: AuthenticatedPrincipal = Depends(require_csrf), + session: Session = Depends(database_session), + request_correlation_id: UUID | None = Depends(correlation_id), +): + context = _authorize(session, principal, workspace_id, Action.MEMBERSHIP_REVOKE) + MembershipService(session).revoke( + context, + membership_id, + reason=reason[:64], + correlation_id=request_correlation_id, + ) + response.status_code = 204 + return response + + +@router.post( + "/v1/workspaces/{workspace_id}/members/{membership_id}/transfer-ownership" +) +def transfer_ownership( + workspace_id: UUID, + membership_id: UUID, + response: Response, + principal: AuthenticatedPrincipal = Depends(require_csrf), + session: Session = Depends(database_session), + request_correlation_id: UUID | None = Depends(correlation_id), +): + context = _authorize( + session, principal, workspace_id, Action.MEMBERSHIP_TRANSFER_OWNERSHIP + ) + target, actor = MembershipService(session).transfer_ownership( + context, membership_id, correlation_id=request_correlation_id + ) + response.headers["ETag"] = _member_etag(target) + return { + "new_owner": _member_payload(target), + "previous_owner": _member_payload(actor), + } + + +# --------------------------------------------------------------------------- +# Invitations +# --------------------------------------------------------------------------- + + +@router.post("/v1/workspaces/{workspace_id}/invitations", status_code=201) +def create_invitation( + workspace_id: UUID, + payload: CreateInvitationRequest, + response: Response, + principal: AuthenticatedPrincipal = Depends(require_csrf), + session: Session = Depends(database_session), + request_correlation_id: UUID | None = Depends(correlation_id), +): + context = _authorize(session, principal, workspace_id, Action.MEMBERSHIP_INVITE) + created = InvitationService(session).create( + context, + invited_user_id=payload.invited_user_id, + role=payload.role, + invited_email=payload.invited_email, + ttl_seconds=payload.expires_in_seconds, + correlation_id=request_correlation_id, + ) + response.headers["ETag"] = _invitation_etag(created.invitation) + response.headers["Location"] = ( + f"/v1/workspaces/{workspace_id}/invitations/{created.invitation.id}" + ) + return { + "invitation": _invitation_payload(created.invitation), + "token": created.token, + } + + +@router.get("/v1/workspaces/{workspace_id}/invitations") +def list_invitations( + workspace_id: UUID, + response: Response, + principal: AuthenticatedPrincipal = Depends(authenticated_principal), + session: Session = Depends(database_session), +): + context = _authorize(session, principal, workspace_id, Action.MEMBERSHIP_READ) + invitations = InvitationService(session).list_for(context) + response.headers["ETag"] = _etag( + "invitations", + [ + str(invitation.id) + invitation.updated_at.isoformat() + invitation.state + for invitation in invitations + ], + ) + return [_invitation_payload(invitation) for invitation in invitations] + + +@router.post("/v1/workspaces/{workspace_id}/invitations/{invitation_id}/revoke") +def revoke_invitation( + workspace_id: UUID, + invitation_id: UUID, + response: Response, + principal: AuthenticatedPrincipal = Depends(require_csrf), + session: Session = Depends(database_session), + request_correlation_id: UUID | None = Depends(correlation_id), +): + context = _authorize(session, principal, workspace_id, Action.MEMBERSHIP_INVITE) + invitation = InvitationService(session).revoke( + context, invitation_id, correlation_id=request_correlation_id + ) + response.headers["ETag"] = _invitation_etag(invitation) + return _invitation_payload(invitation) + + +@router.post( + "/v1/workspaces/{workspace_id}/invitations/{invitation_id}/accept", status_code=201 +) +def accept_invitation( + workspace_id: UUID, + invitation_id: UUID, + payload: AcceptInvitationRequest, + response: Response, + principal: AuthenticatedPrincipal = Depends(require_csrf), + session: Session = Depends(database_session), + request_correlation_id: UUID | None = Depends(correlation_id), +): + membership = InvitationService(session).accept( + principal, + workspace_id=workspace_id, + invitation_id=invitation_id, + token=payload.token, + correlation_id=request_correlation_id, + ) + response.headers["ETag"] = _member_etag(membership) + return _member_payload(membership) diff --git a/services/work-management/src/eventflow_work_management/authorization/__init__.py b/services/work-management/src/eventflow_work_management/authorization/__init__.py new file mode 100644 index 0000000..6fb0ad5 --- /dev/null +++ b/services/work-management/src/eventflow_work_management/authorization/__init__.py @@ -0,0 +1,35 @@ +"""Action-level RBAC for the Work Management bounded context (Issue #9).""" + +from __future__ import annotations + +from eventflow_work_management.authorization.policy import ( + ADMINISTRATOR_ROLES, + ALL_ROLES, + Action, + assignable_roles, + can_administer, + can_assign_role, + can_modify_membership, + is_known_role, + permissions_for, + role_has_permission, +) +from eventflow_work_management.authorization.service import ( + AuthorizationContext, + AuthorizationService, +) + +__all__ = [ + "ADMINISTRATOR_ROLES", + "ALL_ROLES", + "Action", + "AuthorizationContext", + "AuthorizationService", + "assignable_roles", + "can_administer", + "can_assign_role", + "can_modify_membership", + "is_known_role", + "permissions_for", + "role_has_permission", +] diff --git a/services/work-management/src/eventflow_work_management/authorization/policy.py b/services/work-management/src/eventflow_work_management/authorization/policy.py new file mode 100644 index 0000000..6eb970e --- /dev/null +++ b/services/work-management/src/eventflow_work_management/authorization/policy.py @@ -0,0 +1,192 @@ +"""Pure, side-effect-free Work Management RBAC policy (Issue #9). + +The policy answers three questions and nothing else: + +* May a role perform an action inside one workspace? +* May a role be assigned by an actor holding another role? +* May an actor modify a specific membership (protecting the owner seat)? + +It never reads the database. Every decision is a deterministic function of its +arguments, so the matrix can be unit-tested exhaustively and audited in one +place. Enforcement (membership resolution, row locking, HTTP mapping) lives in +``eventflow_work_management.authorization.service``. +""" + +from __future__ import annotations + +from enum import Enum + +from eventflow_work_management.models.enums import WORKSPACE_ROLE_VALUES + +ALL_ROLES: frozenset[str] = frozenset(WORKSPACE_ROLE_VALUES) + + +class Action(str, Enum): + """Action-level permissions; authorization is evaluated per action.""" + + WORKSPACE_READ = "workspace:read" + WORKSPACE_UPDATE = "workspace:update" + WORKSPACE_DELETE = "workspace:delete" + + PROJECT_READ = "project:read" + PROJECT_CREATE = "project:create" + PROJECT_UPDATE = "project:update" + + MEMBERSHIP_READ = "membership:read" + MEMBERSHIP_INVITE = "membership:invite" + MEMBERSHIP_UPDATE_ROLE = "membership:update_role" + MEMBERSHIP_REVOKE = "membership:revoke" + MEMBERSHIP_TRANSFER_OWNERSHIP = "membership:transfer_ownership" + + CONSULTATION_READ = "consultation:read" + CONSULTATION_CREATE = "consultation:create" + CONSULTATION_TRIAGE = "consultation:triage" + + PROPOSAL_READ = "proposal:read" + PROPOSAL_CREATE = "proposal:create" + PROPOSAL_SUBMIT = "proposal:submit" + APPROVAL_DECIDE = "approval:decide" + + WORK_ITEM_READ = "work_item:read" + WORK_ITEM_UPDATE = "work_item:update" + BOARD_READ = "board:read" + BOARD_MANAGE = "board:manage" + + +_READ_ACTIONS: frozenset[Action] = frozenset( + { + Action.WORKSPACE_READ, + Action.PROJECT_READ, + Action.MEMBERSHIP_READ, + Action.CONSULTATION_READ, + Action.PROPOSAL_READ, + Action.WORK_ITEM_READ, + Action.BOARD_READ, + } +) + +_MUTATION_ACTIONS: dict[str, frozenset[Action]] = { + "owner": frozenset( + { + Action.WORKSPACE_UPDATE, + Action.WORKSPACE_DELETE, + Action.PROJECT_CREATE, + Action.PROJECT_UPDATE, + Action.MEMBERSHIP_INVITE, + Action.MEMBERSHIP_UPDATE_ROLE, + Action.MEMBERSHIP_REVOKE, + Action.MEMBERSHIP_TRANSFER_OWNERSHIP, + Action.CONSULTATION_CREATE, + Action.CONSULTATION_TRIAGE, + Action.PROPOSAL_CREATE, + Action.PROPOSAL_SUBMIT, + Action.APPROVAL_DECIDE, + Action.WORK_ITEM_UPDATE, + Action.BOARD_MANAGE, + } + ), + "admin": frozenset( + { + Action.WORKSPACE_UPDATE, + Action.PROJECT_CREATE, + Action.PROJECT_UPDATE, + Action.MEMBERSHIP_INVITE, + Action.MEMBERSHIP_UPDATE_ROLE, + Action.MEMBERSHIP_REVOKE, + Action.CONSULTATION_CREATE, + Action.CONSULTATION_TRIAGE, + Action.PROPOSAL_CREATE, + Action.PROPOSAL_SUBMIT, + Action.APPROVAL_DECIDE, + Action.WORK_ITEM_UPDATE, + Action.BOARD_MANAGE, + } + ), + "triager": frozenset( + { + Action.CONSULTATION_CREATE, + Action.CONSULTATION_TRIAGE, + Action.PROPOSAL_CREATE, + Action.PROPOSAL_SUBMIT, + Action.WORK_ITEM_UPDATE, + } + ), + "approver": frozenset( + { + Action.CONSULTATION_CREATE, + Action.PROPOSAL_CREATE, + Action.PROPOSAL_SUBMIT, + Action.APPROVAL_DECIDE, + } + ), + "contributor": frozenset( + { + Action.CONSULTATION_CREATE, + Action.PROPOSAL_CREATE, + Action.PROPOSAL_SUBMIT, + Action.WORK_ITEM_UPDATE, + Action.BOARD_MANAGE, + } + ), + "viewer": frozenset(), +} + +_ROLE_PERMISSIONS: dict[str, frozenset[Action]] = { + role: _READ_ACTIONS | _MUTATION_ACTIONS.get(role, frozenset()) + for role in ALL_ROLES +} + +# An admin may grant every role except owner; only an owner may create another +# owner. This prevents an admin from escalating to owner or creating a peer it +# cannot later remove. +_ASSIGNABLE_ROLES: dict[str, frozenset[str]] = { + "owner": ALL_ROLES, + "admin": ALL_ROLES - {"owner"}, + "triager": frozenset(), + "approver": frozenset(), + "contributor": frozenset(), + "viewer": frozenset(), +} + +OWNER_ROLE = "owner" +ADMIN_ROLE = "admin" +ADMINISTRATOR_ROLES: frozenset[str] = frozenset({OWNER_ROLE, ADMIN_ROLE}) + + +def is_known_role(role: str) -> bool: + return role in ALL_ROLES + + +def permissions_for(role: str) -> frozenset[Action]: + return _ROLE_PERMISSIONS.get(role, frozenset()) + + +def role_has_permission(role: str, action: Action) -> bool: + """Deny by default: an unknown role or action yields ``False``.""" + return action in _ROLE_PERMISSIONS.get(role, frozenset()) + + +def can_administer(role: str) -> bool: + """Whether the role may change memberships or roles at all.""" + return role in ADMINISTRATOR_ROLES + + +def can_assign_role(actor_role: str, role: str) -> bool: + return role in _ASSIGNABLE_ROLES.get(actor_role, frozenset()) + + +def can_modify_membership(actor_role: str, target_role: str) -> bool: + """Whether an actor may revoke, re-role or transfer away a membership. + + Owners may modify any membership. Admins may modify every membership except + an owner's, so an admin can never demote or remove the owner seat. + """ + if actor_role == OWNER_ROLE: + return True + if actor_role == ADMIN_ROLE: + return target_role != OWNER_ROLE + return False + + +def assignable_roles(actor_role: str) -> frozenset[str]: + return _ASSIGNABLE_ROLES.get(actor_role, frozenset()) diff --git a/services/work-management/src/eventflow_work_management/authorization/service.py b/services/work-management/src/eventflow_work_management/authorization/service.py new file mode 100644 index 0000000..fcc5800 --- /dev/null +++ b/services/work-management/src/eventflow_work_management/authorization/service.py @@ -0,0 +1,101 @@ +"""Tenant-aware authorization: memberships are resolved then actions evaluated.""" + +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from sqlalchemy.orm import Session + +from eventflow_work_management.authorization.policy import ( + Action, + can_modify_membership, + role_has_permission, +) +from eventflow_work_management.models.workspace import Workspace +from eventflow_work_management.models.membership import WorkspaceMembership +from eventflow_work_management.repositories.membership_repository import ( + MembershipRepository, +) +from eventflow_work_management.repositories.workspace_repository import ( + WorkspaceRepository, +) +from eventflow_work_management.security.errors import ProblemError +from eventflow_work_management.security.principal import AuthenticatedPrincipal + +_NOT_FOUND = "The requested resource was not found." +_FORBIDDEN = "The requested action is not permitted." + + +@dataclass(frozen=True, slots=True) +class AuthorizationContext: + """The resolved authority for one request: principal, workspace, membership.""" + + principal: AuthenticatedPrincipal + workspace: Workspace + membership: WorkspaceMembership + + @property + def workspace_id(self) -> UUID: + return self.workspace.id + + @property + def actor_id(self) -> UUID: + return self.principal.user_id + + @property + def role(self) -> str: + return self.membership.role + + +class AuthorizationService: + """Resolves the tenant from the principal, never from the request body. + + Per tenant isolation TI-02/TI-03: a missing workspace, a missing active + membership, and a foreign resource all return ``404`` so existence is never + disclosed; ``403`` is reserved for a visible workspace where the caller's + role lacks the action. + """ + + def __init__(self, session: Session) -> None: + self._session = session + self._workspaces = WorkspaceRepository(session) + self._memberships = MembershipRepository(session) + + def authorize( + self, + principal: AuthenticatedPrincipal, + workspace_id: UUID, + action: Action, + ) -> AuthorizationContext: + workspace = self._workspaces.get(workspace_id) + if workspace is None: + raise self._not_found() + membership = self._memberships.find_active( + workspace_id=workspace_id, user_id=principal.user_id + ) + if membership is None: + # Not a member of the target workspace: hide its existence entirely. + raise self._not_found() + if not role_has_permission(membership.role, action): + raise self._forbidden() + return AuthorizationContext( + principal=principal, workspace=workspace, membership=membership + ) + + @staticmethod + def require_can_modify_membership(actor_role: str, target_role: str) -> None: + if not can_modify_membership(actor_role, target_role): + raise AuthorizationService._forbidden() + + @staticmethod + def _not_found() -> ProblemError: + return ProblemError( + status_code=404, code="NOT_FOUND", detail=_NOT_FOUND, title="Not found" + ) + + @staticmethod + def _forbidden() -> ProblemError: + return ProblemError( + status_code=403, code="FORBIDDEN", detail=_FORBIDDEN, title="Forbidden" + ) diff --git a/services/work-management/src/eventflow_work_management/migrations/versions/0003_workspace_membership_rbac.py b/services/work-management/src/eventflow_work_management/migrations/versions/0003_workspace_membership_rbac.py new file mode 100644 index 0000000..ea040b8 --- /dev/null +++ b/services/work-management/src/eventflow_work_management/migrations/versions/0003_workspace_membership_rbac.py @@ -0,0 +1,93 @@ +"""Add workspace memberships and invitations for RBAC (Issue #9).""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "0003_workspace_membership_rbac" +down_revision = "0002_auth_sessions" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "workspace_memberships", + sa.Column("id", sa.Uuid(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False), + sa.Column("workspace_id", sa.Uuid(as_uuid=True), nullable=False), + sa.Column("user_id", sa.Uuid(as_uuid=True), nullable=False), + sa.Column("role", sa.String(length=16), nullable=False), + sa.Column("state", sa.String(length=16), server_default=sa.text("'active'"), nullable=False), + sa.Column("invited_by", sa.Uuid(as_uuid=True), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoke_reason", sa.String(length=64), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.CheckConstraint("role IN ('viewer', 'contributor', 'triager', 'approver', 'admin', 'owner')", name=op.f("ck_workspace_memberships_role_values")), + sa.CheckConstraint("state IN ('active', 'revoked')", name=op.f("ck_workspace_memberships_state_values")), + sa.CheckConstraint("revoke_reason IS NULL OR char_length(revoke_reason) BETWEEN 1 AND 64", name=op.f("ck_workspace_memberships_revoke_reason_length")), + sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"], name=op.f("fk_workspace_memberships_workspace_id_workspaces"), ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["external_identities.id"], name=op.f("fk_workspace_memberships_user_id_external_identities"), ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["invited_by"], ["external_identities.id"], name=op.f("fk_workspace_memberships_invited_by_external_identities"), ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_workspace_memberships")), + sa.UniqueConstraint("workspace_id", "id", name="uq_workspace_memberships_workspace_id"), + ) + op.create_index("ix_workspace_memberships_workspace_id", "workspace_memberships", ["workspace_id"]) + op.create_index("ix_workspace_memberships_user_id", "workspace_memberships", ["user_id"]) + op.create_index( + "uq_workspace_memberships_active_user", + "workspace_memberships", + ["workspace_id", "user_id"], + unique=True, + postgresql_where=sa.text("state = 'active'"), + ) + + op.create_table( + "workspace_invitations", + sa.Column("id", sa.Uuid(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False), + sa.Column("workspace_id", sa.Uuid(as_uuid=True), nullable=False), + sa.Column("invited_user_id", sa.Uuid(as_uuid=True), nullable=False), + sa.Column("invited_email", sa.String(length=320), nullable=True), + sa.Column("role", sa.String(length=16), nullable=False), + sa.Column("state", sa.String(length=16), server_default=sa.text("'pending'"), nullable=False), + sa.Column("token_digest", sa.String(length=64), nullable=False), + sa.Column("invited_by", sa.Uuid(as_uuid=True), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("accepted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoke_reason", sa.String(length=64), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.CheckConstraint("role IN ('viewer', 'contributor', 'triager', 'approver', 'admin', 'owner')", name=op.f("ck_workspace_invitations_role_values")), + sa.CheckConstraint("state IN ('pending', 'accepted', 'revoked', 'expired')", name=op.f("ck_workspace_invitations_state_values")), + sa.CheckConstraint("token_digest ~ '^[0-9a-f]{64}$'", name=op.f("ck_workspace_invitations_token_digest_format")), + sa.CheckConstraint("invited_email IS NULL OR char_length(invited_email) BETWEEN 3 AND 320", name=op.f("ck_workspace_invitations_invited_email_length")), + sa.CheckConstraint("revoke_reason IS NULL OR char_length(revoke_reason) BETWEEN 1 AND 64", name=op.f("ck_workspace_invitations_revoke_reason_length")), + sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"], name=op.f("fk_workspace_invitations_workspace_id_workspaces"), ondelete="CASCADE"), + sa.ForeignKeyConstraint(["invited_user_id"], ["external_identities.id"], name=op.f("fk_workspace_invitations_invited_user_id_external_identities"), ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["invited_by"], ["external_identities.id"], name=op.f("fk_workspace_invitations_invited_by_external_identities"), ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_workspace_invitations")), + sa.UniqueConstraint("workspace_id", "id", name="uq_workspace_invitations_workspace_id"), + sa.UniqueConstraint("token_digest", name="uq_workspace_invitations_token_digest"), + ) + op.create_index("ix_workspace_invitations_workspace_id", "workspace_invitations", ["workspace_id"]) + op.create_index("ix_workspace_invitations_invited_user_id", "workspace_invitations", ["invited_user_id"]) + op.create_index( + "uq_workspace_invitations_pending_invitee", + "workspace_invitations", + ["workspace_id", "invited_user_id"], + unique=True, + postgresql_where=sa.text("state = 'pending'"), + ) + + +def downgrade() -> None: + op.drop_index("uq_workspace_invitations_pending_invitee", table_name="workspace_invitations") + op.drop_index("ix_workspace_invitations_invited_user_id", table_name="workspace_invitations") + op.drop_index("ix_workspace_invitations_workspace_id", table_name="workspace_invitations") + op.drop_table("workspace_invitations") + op.drop_index("uq_workspace_memberships_active_user", table_name="workspace_memberships") + op.drop_index("ix_workspace_memberships_user_id", table_name="workspace_memberships") + op.drop_index("ix_workspace_memberships_workspace_id", table_name="workspace_memberships") + op.drop_table("workspace_memberships") diff --git a/services/work-management/src/eventflow_work_management/models/__init__.py b/services/work-management/src/eventflow_work_management/models/__init__.py index 57b903e..620dfb7 100644 --- a/services/work-management/src/eventflow_work_management/models/__init__.py +++ b/services/work-management/src/eventflow_work_management/models/__init__.py @@ -18,6 +18,10 @@ from eventflow_work_management.models.consultation import Consultation from eventflow_work_management.models.idempotency import IdempotencyRecord from eventflow_work_management.models.integration import RemoteIntegrationReference +from eventflow_work_management.models.membership import ( + WorkspaceInvitation, + WorkspaceMembership, +) from eventflow_work_management.models.proposal import Proposal from eventflow_work_management.models.work_item import WorkItem from eventflow_work_management.models.workspace import Project, Workspace @@ -38,4 +42,6 @@ "RemoteIntegrationReference", "WorkItem", "Workspace", + "WorkspaceInvitation", + "WorkspaceMembership", ] diff --git a/services/work-management/src/eventflow_work_management/models/enums.py b/services/work-management/src/eventflow_work_management/models/enums.py index 332e0d0..9a74cbb 100644 --- a/services/work-management/src/eventflow_work_management/models/enums.py +++ b/services/work-management/src/eventflow_work_management/models/enums.py @@ -37,3 +37,17 @@ ) REMOTE_PROVIDER_VALUES = ("github", "gitlab") REMOTE_RESOURCE_TYPE_VALUES = ("issue", "pull_request", "merge_request") + +# Work Management authorization vocabulary (Issue #9). The role order is +# intentional: it is roughly ascending privilege, with ``owner`` last so +# assignability and elevation checks can compare positions. +WORKSPACE_ROLE_VALUES = ( + "viewer", + "contributor", + "triager", + "approver", + "admin", + "owner", +) +MEMBERSHIP_STATE_VALUES = ("active", "revoked") +INVITATION_STATE_VALUES = ("pending", "accepted", "revoked", "expired") diff --git a/services/work-management/src/eventflow_work_management/models/membership.py b/services/work-management/src/eventflow_work_management/models/membership.py new file mode 100644 index 0000000..d2525e9 --- /dev/null +++ b/services/work-management/src/eventflow_work_management/models/membership.py @@ -0,0 +1,170 @@ +"""Workspace membership and invitation models (Issue #9). + +A ``WorkspaceMembership`` binds one verified internal identity +(``external_identities.id``) to one workspace with a single role and state. A +``WorkspaceInvitation`` records a pending, identity-bound offer to join a +workspace; the bearer token is never stored, only its digest. +""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID, uuid4 + +from sqlalchemy import ( + CheckConstraint, + DateTime, + ForeignKey, + Index, + String, + UniqueConstraint, + Uuid, + text, +) +from sqlalchemy.orm import Mapped, mapped_column + +from eventflow_work_management.db.base import ( + Base, + TimestampMixin, + check_in, + check_regex, +) +from eventflow_work_management.models.enums import ( + INVITATION_STATE_VALUES, + MEMBERSHIP_STATE_VALUES, + WORKSPACE_ROLE_VALUES, +) + +TOKEN_DIGEST_PATTERN = "^[0-9a-f]{64}$" + + +class WorkspaceMembership(TimestampMixin, Base): + """The tenant-scoped authorization binding for one identity.""" + + __tablename__ = "workspace_memberships" + + id: Mapped[UUID] = mapped_column( + Uuid(as_uuid=True), + primary_key=True, + default=uuid4, + server_default=text("gen_random_uuid()"), + ) + workspace_id: Mapped[UUID] = mapped_column( + Uuid(as_uuid=True), + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, + ) + user_id: Mapped[UUID] = mapped_column( + Uuid(as_uuid=True), + ForeignKey("external_identities.id", ondelete="RESTRICT"), + nullable=False, + ) + role: Mapped[str] = mapped_column(String(16), nullable=False) + state: Mapped[str] = mapped_column( + String(16), nullable=False, server_default=text("'active'") + ) + invited_by: Mapped[UUID | None] = mapped_column( + Uuid(as_uuid=True), + ForeignKey("external_identities.id", ondelete="RESTRICT"), + nullable=True, + ) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + revoke_reason: Mapped[str | None] = mapped_column(String(64), nullable=True) + + __table_args__ = ( + UniqueConstraint( + "workspace_id", "id", name="uq_workspace_memberships_workspace_id" + ), + CheckConstraint(check_in("role", WORKSPACE_ROLE_VALUES), name="role_values"), + CheckConstraint( + check_in("state", MEMBERSHIP_STATE_VALUES), name="state_values" + ), + CheckConstraint( + "revoke_reason IS NULL OR char_length(revoke_reason) BETWEEN 1 AND 64", + name="revoke_reason_length", + ), + # One active membership per identity and workspace; revoked rows are kept + # for the audit trail without blocking re-invitation. + Index( + "uq_workspace_memberships_active_user", + "workspace_id", + "user_id", + unique=True, + postgresql_where=text("state = 'active'"), + ), + Index("ix_workspace_memberships_workspace_id", "workspace_id"), + Index("ix_workspace_memberships_user_id", "user_id"), + ) + + +class WorkspaceInvitation(TimestampMixin, Base): + """A pending, identity-bound offer to join a workspace.""" + + __tablename__ = "workspace_invitations" + + id: Mapped[UUID] = mapped_column( + Uuid(as_uuid=True), + primary_key=True, + default=uuid4, + server_default=text("gen_random_uuid()"), + ) + workspace_id: Mapped[UUID] = mapped_column( + Uuid(as_uuid=True), + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, + ) + invited_user_id: Mapped[UUID] = mapped_column( + Uuid(as_uuid=True), + ForeignKey("external_identities.id", ondelete="RESTRICT"), + nullable=False, + ) + invited_email: Mapped[str | None] = mapped_column(String(320), nullable=True) + role: Mapped[str] = mapped_column(String(16), nullable=False) + state: Mapped[str] = mapped_column( + String(16), nullable=False, server_default=text("'pending'") + ) + token_digest: Mapped[str] = mapped_column(String(64), nullable=False) + invited_by: Mapped[UUID] = mapped_column( + Uuid(as_uuid=True), + ForeignKey("external_identities.id", ondelete="RESTRICT"), + nullable=False, + ) + expires_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + accepted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + revoke_reason: Mapped[str | None] = mapped_column(String(64), nullable=True) + + __table_args__ = ( + UniqueConstraint( + "workspace_id", "id", name="uq_workspace_invitations_workspace_id" + ), + UniqueConstraint("token_digest", name="uq_workspace_invitations_token_digest"), + CheckConstraint(check_in("role", WORKSPACE_ROLE_VALUES), name="role_values"), + CheckConstraint( + check_in("state", INVITATION_STATE_VALUES), name="state_values" + ), + CheckConstraint( + check_regex("token_digest", TOKEN_DIGEST_PATTERN), + name="token_digest_format", + ), + CheckConstraint( + "invited_email IS NULL OR char_length(invited_email) BETWEEN 3 AND 320", + name="invited_email_length", + ), + CheckConstraint( + "revoke_reason IS NULL OR char_length(revoke_reason) BETWEEN 1 AND 64", + name="revoke_reason_length", + ), + # At most one pending invitation per identity and workspace. + Index( + "uq_workspace_invitations_pending_invitee", + "workspace_id", + "invited_user_id", + unique=True, + postgresql_where=text("state = 'pending'"), + ), + Index("ix_workspace_invitations_workspace_id", "workspace_id"), + Index("ix_workspace_invitations_invited_user_id", "invited_user_id"), + ) diff --git a/services/work-management/src/eventflow_work_management/repositories/__init__.py b/services/work-management/src/eventflow_work_management/repositories/__init__.py index 7444676..5d15bd3 100644 --- a/services/work-management/src/eventflow_work_management/repositories/__init__.py +++ b/services/work-management/src/eventflow_work_management/repositories/__init__.py @@ -24,6 +24,12 @@ from eventflow_work_management.repositories.idempotency_repository import ( IdempotencyRepository, ) +from eventflow_work_management.repositories.invitation_repository import ( + InvitationRepository, +) +from eventflow_work_management.repositories.membership_repository import ( + MembershipRepository, +) from eventflow_work_management.repositories.proposal_repository import ProposalRepository from eventflow_work_management.repositories.remote_reference_repository import ( RemoteReferenceRepository, @@ -45,6 +51,8 @@ "EntityNotFound", "IdempotencyConflict", "IdempotencyRepository", + "InvitationRepository", + "MembershipRepository", "ProjectRepository", "ProposalRepository", "RemoteReferenceRepository", diff --git a/services/work-management/src/eventflow_work_management/repositories/invitation_repository.py b/services/work-management/src/eventflow_work_management/repositories/invitation_repository.py new file mode 100644 index 0000000..61679f2 --- /dev/null +++ b/services/work-management/src/eventflow_work_management/repositories/invitation_repository.py @@ -0,0 +1,99 @@ +"""Workspace invitation repository (Issue #9).""" + +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from eventflow_work_management.models.membership import WorkspaceInvitation +from eventflow_work_management.repositories.base import Repository + + +class InvitationRepository(Repository): + """Reads and mutates identity-bound workspace invitations. + + Only the digest of the bearer token is persisted; mutating methods flush and + the service commits once with the audit record. + """ + + def __init__(self, session: Session) -> None: + super().__init__(session) + + def add( + self, + *, + workspace_id: UUID, + invited_user_id: UUID, + invited_email: str | None, + role: str, + token_digest: str, + invited_by: UUID, + expires_at: datetime, + ) -> WorkspaceInvitation: + invitation = WorkspaceInvitation( + id=uuid4(), + workspace_id=workspace_id, + invited_user_id=invited_user_id, + invited_email=invited_email, + role=role, + state="pending", + token_digest=token_digest, + invited_by=invited_by, + expires_at=expires_at, + ) + self._session.add(invitation) + self._session.flush() + return invitation + + def get( + self, *, workspace_id: UUID, invitation_id: UUID + ) -> WorkspaceInvitation | None: + statement = select(WorkspaceInvitation).where( + WorkspaceInvitation.workspace_id == workspace_id, + WorkspaceInvitation.id == invitation_id, + ) + return self._session.execute(statement).scalar_one_or_none() + + def get_by_digest_for_update(self, token_digest: str) -> WorkspaceInvitation | None: + statement = ( + select(WorkspaceInvitation) + .where(WorkspaceInvitation.token_digest == token_digest) + .with_for_update() + ) + return self._session.execute(statement).scalar_one_or_none() + + def find_pending( + self, *, workspace_id: UUID, invited_user_id: UUID + ) -> WorkspaceInvitation | None: + statement = select(WorkspaceInvitation).where( + WorkspaceInvitation.workspace_id == workspace_id, + WorkspaceInvitation.invited_user_id == invited_user_id, + WorkspaceInvitation.state == "pending", + ) + return self._session.execute(statement).scalar_one_or_none() + + def list_for_workspace(self, workspace_id: UUID) -> list[WorkspaceInvitation]: + statement = ( + select(WorkspaceInvitation) + .where(WorkspaceInvitation.workspace_id == workspace_id) + .order_by(WorkspaceInvitation.created_at, WorkspaceInvitation.id) + ) + return list(self._session.execute(statement).scalars()) + + def mark_accepted(self, invitation: WorkspaceInvitation) -> None: + invitation.state = "accepted" + invitation.accepted_at = datetime.now(UTC) + self._session.flush() + + def mark_expired(self, invitation: WorkspaceInvitation) -> None: + invitation.state = "expired" + self._session.flush() + + def revoke(self, invitation: WorkspaceInvitation, *, reason: str) -> None: + invitation.state = "revoked" + invitation.revoked_at = datetime.now(UTC) + invitation.revoke_reason = reason + self._session.flush() diff --git a/services/work-management/src/eventflow_work_management/repositories/membership_repository.py b/services/work-management/src/eventflow_work_management/repositories/membership_repository.py new file mode 100644 index 0000000..6c6dd14 --- /dev/null +++ b/services/work-management/src/eventflow_work_management/repositories/membership_repository.py @@ -0,0 +1,114 @@ +"""Workspace membership repository (Issue #9).""" + +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from eventflow_work_management.models.membership import WorkspaceMembership +from eventflow_work_management.models.workspace import Workspace +from eventflow_work_management.repositories.base import Repository + + +class MembershipRepository(Repository): + """Reads and mutates tenant-scoped authorization bindings. + + Mutating methods only ``flush``; the owning service commits exactly once so + a membership change and its audit record share one transaction. + """ + + def __init__(self, session: Session) -> None: + super().__init__(session) + + def find_active( + self, *, workspace_id: UUID, user_id: UUID + ) -> WorkspaceMembership | None: + statement = select(WorkspaceMembership).where( + WorkspaceMembership.workspace_id == workspace_id, + WorkspaceMembership.user_id == user_id, + WorkspaceMembership.state == "active", + ) + return self._session.execute(statement).scalar_one_or_none() + + def get( + self, *, workspace_id: UUID, membership_id: UUID + ) -> WorkspaceMembership | None: + statement = select(WorkspaceMembership).where( + WorkspaceMembership.workspace_id == workspace_id, + WorkspaceMembership.id == membership_id, + ) + return self._session.execute(statement).scalar_one_or_none() + + def list_for_workspace(self, workspace_id: UUID) -> list[WorkspaceMembership]: + statement = ( + select(WorkspaceMembership) + .where(WorkspaceMembership.workspace_id == workspace_id) + .order_by(WorkspaceMembership.created_at, WorkspaceMembership.id) + ) + return list(self._session.execute(statement).scalars()) + + def add( + self, + *, + workspace_id: UUID, + user_id: UUID, + role: str, + invited_by: UUID | None = None, + ) -> WorkspaceMembership: + membership = WorkspaceMembership( + id=uuid4(), + workspace_id=workspace_id, + user_id=user_id, + role=role, + state="active", + invited_by=invited_by, + ) + self._session.add(membership) + self._session.flush() + return membership + + def count_active_owners(self, workspace_id: UUID) -> int: + statement = ( + select(func.count()) + .select_from(WorkspaceMembership) + .where( + WorkspaceMembership.workspace_id == workspace_id, + WorkspaceMembership.role == "owner", + WorkspaceMembership.state == "active", + ) + ) + return int(self._session.execute(statement).scalar_one()) + + def change_role(self, membership: WorkspaceMembership, role: str) -> None: + membership.role = role + self._session.flush() + + def revoke(self, membership: WorkspaceMembership, *, reason: str) -> None: + membership.state = "revoked" + membership.revoked_at = datetime.now(UTC) + membership.revoke_reason = reason + self._session.flush() + + def reactivate(self, membership: WorkspaceMembership, *, role: str) -> None: + membership.state = "active" + membership.role = role + membership.revoked_at = None + membership.revoke_reason = None + self._session.flush() + + def lock_workspace(self, workspace_id: UUID) -> bool: + """Take a row lock on the workspace to serialize membership invariants. + + Returns ``False`` when the workspace row is absent. Concurrent owner + changes for one workspace queue on this lock, so the "at least one + owner" invariant is evaluated against a stable membership set. + """ + statement = ( + select(Workspace.id) + .where(Workspace.id == workspace_id) + .with_for_update() + ) + return self._session.execute(statement).scalar_one_or_none() is not None diff --git a/services/work-management/src/eventflow_work_management/repositories/workspace_repository.py b/services/work-management/src/eventflow_work_management/repositories/workspace_repository.py index c6dc409..5854062 100644 --- a/services/work-management/src/eventflow_work_management/repositories/workspace_repository.py +++ b/services/work-management/src/eventflow_work_management/repositories/workspace_repository.py @@ -8,6 +8,7 @@ from sqlalchemy.orm import Session from eventflow_work_management.models import Project, Workspace +from eventflow_work_management.models.membership import WorkspaceMembership from eventflow_work_management.repositories.base import Repository @@ -21,9 +22,51 @@ def create(self, *, name: str, slug: str) -> Workspace: self._session.commit() return workspace + def add(self, *, name: str, slug: str) -> Workspace: + """Add a workspace and flush without committing. + + The owning service commits once so workspace creation, the founding + owner membership, and the audit record are atomic. + """ + workspace = Workspace(id=uuid4(), name=name, slug=slug) + self._session.add(workspace) + self._session.flush() + return workspace + def get(self, workspace_id: UUID) -> Workspace | None: return self._session.get(Workspace, workspace_id) + def find_by_slug(self, slug: str) -> Workspace | None: + statement = select(Workspace).where(Workspace.slug == slug) + return self._session.execute(statement).scalar_one_or_none() + + def get_for_update(self, workspace_id: UUID) -> Workspace | None: + statement = ( + select(Workspace) + .where(Workspace.id == workspace_id) + .with_for_update() + ) + return self._session.execute(statement).scalar_one_or_none() + + def list_for_user(self, user_id: UUID) -> list[Workspace]: + statement = ( + select(Workspace) + .join( + WorkspaceMembership, + WorkspaceMembership.workspace_id == Workspace.id, + ) + .where( + WorkspaceMembership.user_id == user_id, + WorkspaceMembership.state == "active", + ) + .order_by(Workspace.created_at, Workspace.id) + ) + return list(self._session.execute(statement).scalars()) + + def update_name(self, workspace: Workspace, name: str) -> None: + workspace.name = name + self._session.flush() + class ProjectRepository(Repository): def __init__(self, session: Session) -> None: @@ -48,8 +91,49 @@ def create( self._session.commit() return project + def add( + self, + *, + workspace_id: UUID, + key: str, + name: str, + description: str | None = None, + ) -> Project: + project = Project( + id=uuid4(), + workspace_id=workspace_id, + key=key, + name=name, + description=description, + ) + self._session.add(project) + self._session.flush() + return project + def get(self, *, workspace_id: UUID, project_id: UUID) -> Project | None: statement = select(Project).where( Project.workspace_id == workspace_id, Project.id == project_id ) return self._session.execute(statement).scalar_one_or_none() + + def list_for_workspace(self, workspace_id: UUID) -> list[Project]: + statement = ( + select(Project) + .where(Project.workspace_id == workspace_id) + .order_by(Project.created_at, Project.id) + ) + return list(self._session.execute(statement).scalars()) + + def update( + self, + project: Project, + *, + name: str | None, + description: str | None, + update_description: bool, + ) -> None: + if name is not None: + project.name = name + if update_description: + project.description = description + self._session.flush() diff --git a/services/work-management/src/eventflow_work_management/security/errors.py b/services/work-management/src/eventflow_work_management/security/errors.py index b7be24b..4fc5300 100644 --- a/services/work-management/src/eventflow_work_management/security/errors.py +++ b/services/work-management/src/eventflow_work_management/security/errors.py @@ -19,3 +19,25 @@ def __init__( class AuthenticationDependencyError(Exception): """A required authentication dependency is temporarily unavailable.""" + + +class ProblemError(Exception): + """A sanitized, HTTP-mappable problem raised by the domain and authorization. + + Carries the RFC 9457 members the transport layer needs without leaking + internal state. The message is deliberately generic and safe to return. + """ + + def __init__( + self, + *, + status_code: int, + code: str, + detail: str, + title: str | None = None, + ) -> None: + super().__init__(detail) + self.status_code = status_code + self.code = code + self.detail = detail + self.title = title diff --git a/services/work-management/src/eventflow_work_management/services/__init__.py b/services/work-management/src/eventflow_work_management/services/__init__.py new file mode 100644 index 0000000..25d96cc --- /dev/null +++ b/services/work-management/src/eventflow_work_management/services/__init__.py @@ -0,0 +1,19 @@ +"""Application services for workspace administration and RBAC (Issue #9).""" + +from __future__ import annotations + +from eventflow_work_management.services.workspace_management import ( + CreatedInvitation, + InvitationService, + MembershipService, + ProjectService, + WorkspaceService, +) + +__all__ = [ + "CreatedInvitation", + "InvitationService", + "MembershipService", + "ProjectService", + "WorkspaceService", +] diff --git a/services/work-management/src/eventflow_work_management/services/workspace_management.py b/services/work-management/src/eventflow_work_management/services/workspace_management.py new file mode 100644 index 0000000..3bea0a6 --- /dev/null +++ b/services/work-management/src/eventflow_work_management/services/workspace_management.py @@ -0,0 +1,514 @@ +"""Workspace, project, membership and invitation application services (Issue #9). + +Each public method is one unit of work: it authorizes through +``AuthorizationService``, performs a single transactional change, writes an +append-only audit record, and commits exactly once. Membership invariants that +span rows (at least one owner) are serialized with a workspace row lock. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from uuid import UUID + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from eventflow_work_management.authorization.policy import ( + Action, + can_assign_role, + is_known_role, +) +from eventflow_work_management.authorization.service import ( + AuthorizationContext, + AuthorizationService, +) +from eventflow_work_management.models.authentication import ExternalIdentity +from eventflow_work_management.models.membership import ( + WorkspaceInvitation, + WorkspaceMembership, +) +from eventflow_work_management.models.workspace import Project, Workspace +from eventflow_work_management.repositories.audit_repository import AuditRepository +from eventflow_work_management.repositories.invitation_repository import ( + InvitationRepository, +) +from eventflow_work_management.repositories.membership_repository import ( + MembershipRepository, +) +from eventflow_work_management.repositories.workspace_repository import ( + ProjectRepository, + WorkspaceRepository, +) +from eventflow_work_management.security.crypto import digest_token, random_url_token +from eventflow_work_management.security.errors import ProblemError +from eventflow_work_management.security.principal import AuthenticatedPrincipal + +DEFAULT_INVITATION_TTL_SECONDS = 14 * 24 * 60 * 60 +MIN_INVITATION_TTL_SECONDS = 5 * 60 +MAX_INVITATION_TTL_SECONDS = 30 * 24 * 60 * 60 + +_NOT_FOUND = "The requested resource was not found." +_FORBIDDEN = "The requested action is not permitted." + + +def _now() -> datetime: + return datetime.now(UTC) + + +def _validation(detail: str) -> ProblemError: + return ProblemError( + status_code=400, code="VALIDATION_ERROR", detail=detail, title="Invalid request" + ) + + +def _not_found() -> ProblemError: + return ProblemError( + status_code=404, code="NOT_FOUND", detail=_NOT_FOUND, title="Not found" + ) + + +def _forbidden() -> ProblemError: + return ProblemError( + status_code=403, code="FORBIDDEN", detail=_FORBIDDEN, title="Forbidden" + ) + + +def _conflict(detail: str) -> ProblemError: + return ProblemError( + status_code=409, code="CONFLICT", detail=detail, title="Conflict" + ) + + +@dataclass(frozen=True, slots=True) +class CreatedInvitation: + """A pending invitation and the one-time bearer token to deliver to the invitee.""" + + invitation: WorkspaceInvitation + token: str + + +class _Service: + def __init__(self, session: Session) -> None: + self._session = session + self.workspaces = WorkspaceRepository(session) + self.projects = ProjectRepository(session) + self.memberships = MembershipRepository(session) + self.invitations = InvitationRepository(session) + self.audit = AuditRepository(session) + + +class WorkspaceService(_Service): + def create( + self, + principal: AuthenticatedPrincipal, + *, + name: str, + slug: str, + correlation_id: UUID | None = None, + ) -> Workspace: + """Create a workspace and its founding owner membership atomically. + + Any authenticated principal may create a workspace; the creator becomes + its owner. A concurrent duplicate slug loses the race on the unique + constraint and receives ``409`` rather than a partial workspace. + """ + if self.workspaces.find_by_slug(slug) is not None: + raise _conflict("A workspace with this slug already exists.") + try: + workspace = self.workspaces.add(name=name, slug=slug) + self.memberships.add( + workspace_id=workspace.id, + user_id=principal.user_id, + role="owner", + ) + self.audit.append( + workspace_id=workspace.id, + action="workspace.created", + entity_type="workspace", + entity_id=workspace.id, + actor_id=principal.user_id, + snapshot={"name": name, "slug": slug, "role": "owner"}, + correlation_id=correlation_id, + ) + self._session.commit() + except IntegrityError as exc: + self._session.rollback() + raise _conflict("A workspace with this slug already exists.") from exc + return workspace + + def list_for(self, principal: AuthenticatedPrincipal) -> list[Workspace]: + return self.workspaces.list_for_user(principal.user_id) + + def get(self, context: AuthorizationContext) -> Workspace: + return context.workspace + + def update( + self, + context: AuthorizationContext, + *, + name: str, + correlation_id: UUID | None = None, + ) -> Workspace: + workspace = context.workspace + self.workspaces.update_name(workspace, name) + self.audit.append( + workspace_id=workspace.id, + action="workspace.updated", + entity_type="workspace", + entity_id=workspace.id, + actor_id=context.actor_id, + snapshot={"name": name}, + correlation_id=correlation_id, + ) + self._session.commit() + return workspace + + +class ProjectService(_Service): + def create( + self, + context: AuthorizationContext, + *, + key: str, + name: str, + description: str | None = None, + correlation_id: UUID | None = None, + ) -> Project: + try: + project = self.projects.add( + workspace_id=context.workspace_id, + key=key, + name=name, + description=description, + ) + self.audit.append( + workspace_id=context.workspace_id, + action="project.created", + entity_type="project", + entity_id=project.id, + actor_id=context.actor_id, + snapshot={"key": key, "name": name}, + correlation_id=correlation_id, + ) + self._session.commit() + except IntegrityError as exc: + self._session.rollback() + raise _conflict("A project with this key already exists.") from exc + return project + + def list(self, context: AuthorizationContext) -> list[Project]: + return self.projects.list_for_workspace(context.workspace_id) + + def get(self, context: AuthorizationContext, project_id: UUID) -> Project: + project = self.projects.get( + workspace_id=context.workspace_id, project_id=project_id + ) + if project is None: + raise _not_found() + return project + + def update( + self, + context: AuthorizationContext, + project_id: UUID, + *, + name: str | None, + description: str | None, + update_description: bool, + correlation_id: UUID | None = None, + ) -> Project: + project = self.get(context, project_id) + self.projects.update( + project, + name=name, + description=description, + update_description=update_description, + ) + self.audit.append( + workspace_id=context.workspace_id, + action="project.updated", + entity_type="project", + entity_id=project.id, + actor_id=context.actor_id, + snapshot={"name": name, "description": description}, + correlation_id=correlation_id, + ) + self._session.commit() + return project + + +class MembershipService(_Service): + def list(self, context: AuthorizationContext) -> list[WorkspaceMembership]: + return self.memberships.list_for_workspace(context.workspace_id) + + def change_role( + self, + context: AuthorizationContext, + membership_id: UUID, + *, + role: str, + correlation_id: UUID | None = None, + ) -> WorkspaceMembership: + if not is_known_role(role): + raise _validation("Unknown role.") + if not can_assign_role(context.role, role): + raise _forbidden() + self.memberships.lock_workspace(context.workspace_id) + target = self._active_membership(context, membership_id) + AuthorizationService.require_can_modify_membership(context.role, target.role) + if target.role == "owner" and role != "owner": + self._require_another_owner(context.workspace_id) + previous_role = target.role + self.memberships.change_role(target, role) + self.audit.append( + workspace_id=context.workspace_id, + action="membership.role_changed", + entity_type="membership", + entity_id=target.id, + actor_id=context.actor_id, + snapshot={"from": previous_role, "to": role, "user_id": str(target.user_id)}, + correlation_id=correlation_id, + ) + self._session.commit() + return target + + def revoke( + self, + context: AuthorizationContext, + membership_id: UUID, + *, + reason: str, + correlation_id: UUID | None = None, + ) -> WorkspaceMembership: + self.memberships.lock_workspace(context.workspace_id) + target = self._active_membership(context, membership_id) + AuthorizationService.require_can_modify_membership(context.role, target.role) + if target.role == "owner": + self._require_another_owner(context.workspace_id) + self.memberships.revoke(target, reason=reason) + self.audit.append( + workspace_id=context.workspace_id, + action="membership.revoked", + entity_type="membership", + entity_id=target.id, + actor_id=context.actor_id, + snapshot={"user_id": str(target.user_id), "role": target.role}, + correlation_id=correlation_id, + ) + self._session.commit() + return target + + def transfer_ownership( + self, + context: AuthorizationContext, + membership_id: UUID, + *, + correlation_id: UUID | None = None, + ) -> tuple[WorkspaceMembership, WorkspaceMembership]: + """Make one active member the owner and step the acting owner down to admin.""" + self.memberships.lock_workspace(context.workspace_id) + if membership_id == context.membership.id: + raise _validation("Ownership must be transferred to another member.") + target = self._active_membership(context, membership_id) + actor = context.membership + self.memberships.change_role(target, "owner") + self.memberships.change_role(actor, "admin") + self.audit.append( + workspace_id=context.workspace_id, + action="membership.ownership_transferred", + entity_type="membership", + entity_id=target.id, + actor_id=context.actor_id, + snapshot={ + "new_owner_membership_id": str(target.id), + "previous_owner_membership_id": str(actor.id), + }, + correlation_id=correlation_id, + ) + self._session.commit() + return target, actor + + def _active_membership( + self, context: AuthorizationContext, membership_id: UUID + ) -> WorkspaceMembership: + target = self.memberships.get( + workspace_id=context.workspace_id, membership_id=membership_id + ) + if target is None or target.state != "active": + raise _not_found() + return target + + def _require_another_owner(self, workspace_id: UUID) -> None: + if self.memberships.count_active_owners(workspace_id) <= 1: + raise _conflict("A workspace must retain at least one owner.") + + +class InvitationService(_Service): + def create( + self, + context: AuthorizationContext, + *, + invited_user_id: UUID, + role: str, + invited_email: str | None = None, + ttl_seconds: int = DEFAULT_INVITATION_TTL_SECONDS, + correlation_id: UUID | None = None, + ) -> CreatedInvitation: + if not is_known_role(role): + raise _validation("Unknown role.") + if not can_assign_role(context.role, role): + raise _forbidden() + if not MIN_INVITATION_TTL_SECONDS <= ttl_seconds <= MAX_INVITATION_TTL_SECONDS: + raise _validation("Invitation lifetime is out of range.") + invitee = self._session.get(ExternalIdentity, invited_user_id) + if invitee is None or invitee.disabled_at is not None: + raise _not_found() + if ( + self.memberships.find_active( + workspace_id=context.workspace_id, user_id=invited_user_id + ) + is not None + ): + raise _conflict("The identity is already an active member.") + if ( + self.invitations.find_pending( + workspace_id=context.workspace_id, invited_user_id=invited_user_id + ) + is not None + ): + raise _conflict("A pending invitation already exists for this identity.") + + token = random_url_token(32) + expires_at = _now() + timedelta(seconds=ttl_seconds) + email = invited_email.lower() if invited_email else None + try: + invitation = self.invitations.add( + workspace_id=context.workspace_id, + invited_user_id=invited_user_id, + invited_email=email, + role=role, + token_digest=digest_token(token), + invited_by=context.actor_id, + expires_at=expires_at, + ) + self.audit.append( + workspace_id=context.workspace_id, + action="membership.invited", + entity_type="invitation", + entity_id=invitation.id, + actor_id=context.actor_id, + snapshot={ + "invited_user_id": str(invited_user_id), + "role": role, + "expires_at": expires_at.isoformat(), + }, + correlation_id=correlation_id, + ) + self._session.commit() + except IntegrityError as exc: + self._session.rollback() + raise _conflict( + "A pending invitation already exists for this identity." + ) from exc + return CreatedInvitation(invitation=invitation, token=token) + + def list_for(self, context: AuthorizationContext) -> list[WorkspaceInvitation]: + return self.invitations.list_for_workspace(context.workspace_id) + + def revoke( + self, + context: AuthorizationContext, + invitation_id: UUID, + *, + reason: str = "revoked_by_administrator", + correlation_id: UUID | None = None, + ) -> WorkspaceInvitation: + invitation = self.invitations.get( + workspace_id=context.workspace_id, invitation_id=invitation_id + ) + if invitation is None: + raise _not_found() + if invitation.state != "pending": + raise _conflict("Only a pending invitation can be revoked.") + self.invitations.revoke(invitation, reason=reason) + self.audit.append( + workspace_id=context.workspace_id, + action="membership.invitation_revoked", + entity_type="invitation", + entity_id=invitation.id, + actor_id=context.actor_id, + snapshot={"invited_user_id": str(invitation.invited_user_id)}, + correlation_id=correlation_id, + ) + self._session.commit() + return invitation + + def accept( + self, + principal: AuthenticatedPrincipal, + *, + workspace_id: UUID, + invitation_id: UUID, + token: str, + correlation_id: UUID | None = None, + ) -> WorkspaceMembership: + """Accept an invitation and create the membership. + + Authorization is identity-bound: the authenticated principal must be the + invited identity and must present the exact token digest. A wrong + identity, unknown token, foreign invitation, or a consumed/withdrawn + token is indistinguishable from "not found" and returns ``404``; only a + pending invitation that has passed its expiry returns ``409``. + """ + if not token: + raise _validation("An invitation token is required.") + invitation = self.invitations.get_by_digest_for_update(digest_token(token)) + if ( + invitation is None + or invitation.id != invitation_id + or invitation.workspace_id != workspace_id + or invitation.invited_user_id != principal.user_id + ): + raise _not_found() + if invitation.state in {"accepted", "revoked"}: + # A consumed or withdrawn token is indistinguishable from an unknown + # one, so a replay cannot confirm that the token was ever valid. + raise _not_found() + if invitation.state == "expired" or ( + invitation.state == "pending" and invitation.expires_at <= _now() + ): + if invitation.state == "pending": + self.invitations.mark_expired(invitation) + self._session.commit() + raise _conflict("The invitation has expired.") + + self.memberships.lock_workspace(workspace_id) + existing = self.memberships.find_active( + workspace_id=workspace_id, user_id=principal.user_id + ) + if existing is not None: + raise _conflict("The identity is already an active member.") + membership = self.memberships.add( + workspace_id=workspace_id, + user_id=principal.user_id, + role=invitation.role, + invited_by=invitation.invited_by, + ) + self.invitations.mark_accepted(invitation) + self.audit.append( + workspace_id=workspace_id, + action="membership.accepted", + entity_type="membership", + entity_id=membership.id, + actor_id=principal.user_id, + snapshot={"invitation_id": str(invitation.id), "role": invitation.role}, + correlation_id=correlation_id, + ) + try: + self._session.commit() + except IntegrityError as exc: + self._session.rollback() + raise _conflict("The identity is already an active member.") from exc + return membership diff --git a/services/work-management/tests/test_migrations_integration.py b/services/work-management/tests/test_migrations_integration.py index ee13a62..8c3f0de 100644 --- a/services/work-management/tests/test_migrations_integration.py +++ b/services/work-management/tests/test_migrations_integration.py @@ -23,6 +23,12 @@ "audit_records", "idempotency_records", "remote_integration_references", + "external_identities", + "authentication_sessions", + "oidc_login_transactions", + "authentication_security_events", + "workspace_memberships", + "workspace_invitations", } EXPECTED_TRIGGERS = { @@ -92,7 +98,7 @@ def test_upgrade_creates_schema_guards_and_revision(): tables = _tables(engine) assert EXPECTED_TABLES <= tables assert "alembic_version" in tables - assert _revision(engine) == "0002_auth_sessions" + assert _revision(engine) == "0003_workspace_membership_rbac" assert EXPECTED_TRIGGERS <= _triggers(engine) assert EXPECTED_FUNCTIONS <= _functions(engine) assert EXPECTED_CONSTRAINTS <= _constraints(engine) @@ -103,7 +109,7 @@ def test_upgrade_is_idempotent(): with temporary_empty_database(admin_url) as (url, engine): run_migrations(url) run_migrations(url) - assert _revision(engine) == "0002_auth_sessions" + assert _revision(engine) == "0003_workspace_membership_rbac" def test_downgrade_removes_tables_triggers_and_functions(): diff --git a/services/work-management/tests/test_models_unit.py b/services/work-management/tests/test_models_unit.py index cb3f604..ccf3486 100644 --- a/services/work-management/tests/test_models_unit.py +++ b/services/work-management/tests/test_models_unit.py @@ -25,6 +25,8 @@ "audit_records", "idempotency_records", "remote_integration_references", + "workspace_memberships", + "workspace_invitations", } AUTHENTICATION_TABLES = { diff --git a/services/work-management/tests/test_rbac_policy_unit.py b/services/work-management/tests/test_rbac_policy_unit.py new file mode 100644 index 0000000..b138c6b --- /dev/null +++ b/services/work-management/tests/test_rbac_policy_unit.py @@ -0,0 +1,130 @@ +"""Unit tests for the Work Management RBAC policy (no database required). + +These tests pin the action matrix, the role-assignment rules, and the owner +protection, and cross-check the policy against the ``x-required-roles`` declared +in the public OpenAPI contract so the two cannot drift. +""" + +from __future__ import annotations + +import pathlib + +import yaml +from eventflow_work_management.authorization.policy import ( + ALL_ROLES, + Action, + assignable_roles, + can_administer, + can_assign_role, + can_modify_membership, + is_known_role, + role_has_permission, +) + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[3] +OPENAPI_PATH = REPO_ROOT / "contracts" / "openapi" / "work-management.yaml" + + +def _roles_for(action: Action) -> set[str]: + return {role for role in ALL_ROLES if role_has_permission(role, action)} + + +def test_deny_by_default_for_unknown_role_and_action(): + assert is_known_role("owner") is True + assert is_known_role("superuser") is False + assert role_has_permission("superuser", Action.WORKSPACE_READ) is False + assert role_has_permission("viewer", Action.WORKSPACE_DELETE) is False + + +def test_viewer_is_read_only(): + assert role_has_permission("viewer", Action.WORKSPACE_READ) + assert role_has_permission("viewer", Action.PROJECT_READ) + assert not role_has_permission("viewer", Action.PROJECT_CREATE) + assert not role_has_permission("viewer", Action.MEMBERSHIP_INVITE) + assert not role_has_permission("viewer", Action.CONSULTATION_CREATE) + + +def test_role_specific_mutations(): + assert role_has_permission("contributor", Action.CONSULTATION_CREATE) + assert role_has_permission("contributor", Action.BOARD_MANAGE) + assert not role_has_permission("contributor", Action.APPROVAL_DECIDE) + assert not role_has_permission("contributor", Action.CONSULTATION_TRIAGE) + + assert role_has_permission("approver", Action.APPROVAL_DECIDE) + assert not role_has_permission("approver", Action.CONSULTATION_TRIAGE) + + assert role_has_permission("triager", Action.CONSULTATION_TRIAGE) + assert not role_has_permission("triager", Action.APPROVAL_DECIDE) + + +def test_owner_has_every_action(): + for action in Action: + assert role_has_permission("owner", action), action + + +def test_admin_cannot_grant_or_modify_the_owner_seat(): + assert can_administer("admin") is True + assert can_administer("triager") is False + assert can_assign_role("admin", "owner") is False + assert can_assign_role("admin", "contributor") is True + assert can_assign_role("owner", "owner") is True + assert can_assign_role("triager", "contributor") is False + + assert can_modify_membership("admin", "owner") is False + assert can_modify_membership("admin", "admin") is True + assert can_modify_membership("owner", "owner") is True + assert can_modify_membership("contributor", "viewer") is False + assert assignable_roles("admin") == ALL_ROLES - {"owner"} + + +def test_policy_matches_declared_openapi_roles(): + with OPENAPI_PATH.open(encoding="utf-8") as stream: + openapi = yaml.safe_load(stream) + + expected = { + "createWorkspace": None, # authenticated-only, see x-authorization-scope + "updateWorkspace": Action.WORKSPACE_UPDATE, + "createProject": Action.PROJECT_CREATE, + "updateProject": Action.PROJECT_UPDATE, + "changeMemberRole": Action.MEMBERSHIP_UPDATE_ROLE, + "revokeMember": Action.MEMBERSHIP_REVOKE, + "transferOwnership": Action.MEMBERSHIP_TRANSFER_OWNERSHIP, + "createInvitation": Action.MEMBERSHIP_INVITE, + "createConsultation": Action.CONSULTATION_CREATE, + "triageConsultation": Action.CONSULTATION_TRIAGE, + "decideProposal": Action.APPROVAL_DECIDE, + "moveBoardCard": Action.BOARD_MANAGE, + } + operations = {} + for item in openapi["paths"].values(): + for method, operation in item.items(): + if method in {"get", "post", "put", "patch", "delete"}: + operations[operation["operationId"]] = operation + + for operation_id, action in expected.items(): + operation = operations[operation_id] + declared = set(operation["x-required-roles"]) + if action is None: + assert operation.get("x-authorization-scope") == "authenticated" + continue + assert declared == _roles_for(action), ( + f"{operation_id} declares {sorted(declared)} but policy grants " + f"{sorted(_roles_for(action))}" + ) + + +def test_authenticated_only_operations_are_declared(): + with OPENAPI_PATH.open(encoding="utf-8") as stream: + openapi = yaml.safe_load(stream) + authenticated_only = { + operation["operationId"] + for item in openapi["paths"].values() + for method, operation in item.items() + if method in {"get", "post", "put", "patch", "delete"} + and operation.get("x-authorization-scope") == "authenticated" + } + assert authenticated_only == { + "createWorkspace", + "listWorkspaces", + "acceptInvitation", + } diff --git a/services/work-management/tests/test_workspace_api_integration.py b/services/work-management/tests/test_workspace_api_integration.py new file mode 100644 index 0000000..bbacc95 --- /dev/null +++ b/services/work-management/tests/test_workspace_api_integration.py @@ -0,0 +1,169 @@ +"""HTTP integration tests for the workspace administration API (Issue #9). + +The app is built against a throwaway migrated database and a deterministic +authentication stub that maps an opaque bearer token to an internal identity. +The tests exercise the public contract behaviour: ETag/If-Match, non-disclosive +404s for non-members, role-gated writes, and identity-bound invitation +acceptance. They skip themselves when no test database is configured. +""" + +from __future__ import annotations + +from fastapi.testclient import TestClient +from eventflow_work_management.api.app import create_app +from eventflow_work_management.db.engine import session_factory +from eventflow_work_management.security.config import AuthSettings +from eventflow_work_management.security.crypto import TokenCipher +from support import require_database_url, temporary_database + +ISSUER = "https://id.example.test/realms/eventflow" + + +class FakeOIDC: + """Maps each bearer token to a distinct internal identity.""" + + def validate_access_token(self, token: str) -> dict: + return {"iss": ISSUER, "sub": token} + + def close(self) -> None: # pragma: no cover - lifecycle no-op + return None + + +def _settings() -> AuthSettings: + return AuthSettings( + environment="test", + issuer=ISSUER, + audience="eventflow-api", + web_client_id="eventflow-web-bff", + native_client_ids=("eventflow-desktop", "eventflow-vscode"), + redirect_uri="https://api.example.test/v1/auth/oidc/callback", + frontend_redirect_uri="https://web.example.test/auth/complete", + allowed_origins=("https://web.example.test",), + token_encryption_keys=(TokenCipher.generate_key(),), + ) + + +def _bearer(subject: str) -> dict: + return {"Authorization": f"Bearer {subject}"} + + +def _principal_id(client: TestClient, subject: str) -> str: + response = client.get("/v1/auth/principal", headers=_bearer(subject)) + assert response.status_code == 200, response.text + return response.json()["user_id"] + + +def test_workspace_isolation_and_etag_enforcement(): + with temporary_database(require_database_url()) as (_, engine): + settings = _settings() + app = create_app( + settings=settings, + session_factory_override=session_factory(engine), + oidc_client=FakeOIDC(), + ) + with TestClient(app) as client: + created = client.post( + "/v1/workspaces", + json={"name": "Acme Inc", "slug": "acme-api"}, + headers=_bearer("alice"), + ) + assert created.status_code == 201, created.text + workspace_id = created.json()["workspace_id"] + etag = created.headers["ETag"] + + assert client.get(f"/v1/workspaces/{workspace_id}").status_code == 401 + + listed = client.get("/v1/workspaces", headers=_bearer("alice")) + assert [row["workspace_id"] for row in listed.json()] == [workspace_id] + + # A non-member must not be able to distinguish "exists" from "foreign". + assert ( + client.get( + f"/v1/workspaces/{workspace_id}", headers=_bearer("bob") + ).status_code + == 404 + ) + assert ( + client.post( + f"/v1/workspaces/{workspace_id}/projects", + json={"key": "DEMO", "name": "Demo Project"}, + headers=_bearer("bob"), + ).status_code + == 404 + ) + + # A stale If-Match validator yields 412. + stale = client.patch( + f"/v1/workspaces/{workspace_id}", + json={"name": "Acme Renamed"}, + headers={**_bearer("alice"), "If-Match": '"deadbeef"'}, + ) + assert stale.status_code == 412 + + ok = client.patch( + f"/v1/workspaces/{workspace_id}", + json={"name": "Acme Renamed"}, + headers={**_bearer("alice"), "If-Match": etag}, + ) + assert ok.status_code == 200 + assert ok.headers["ETag"] != etag + + +def test_role_gating_and_invitation_acceptance_over_http(): + with temporary_database(require_database_url()) as (_, engine): + settings = _settings() + app = create_app( + settings=settings, + session_factory_override=session_factory(engine), + oidc_client=FakeOIDC(), + ) + with TestClient(app) as client: + workspace_id = client.post( + "/v1/workspaces", + json={"name": "Beta Co", "slug": "beta-api"}, + headers=_bearer("owner"), + ).json()["workspace_id"] + bob_id = _principal_id(client, "bob") + + invitation = client.post( + f"/v1/workspaces/{workspace_id}/invitations", + json={"invited_user_id": bob_id, "role": "contributor"}, + headers=_bearer("owner"), + ) + assert invitation.status_code == 201, invitation.text + body = invitation.json() + token = body["token"] + invitation_id = body["invitation"]["invitation_id"] + + # The wrong identity cannot accept the invitation. + assert ( + client.post( + f"/v1/workspaces/{workspace_id}/invitations/{invitation_id}/accept", + json={"token": token}, + headers=_bearer("carol"), + ).status_code + == 404 + ) + + accepted = client.post( + f"/v1/workspaces/{workspace_id}/invitations/{invitation_id}/accept", + json={"token": token}, + headers=_bearer("bob"), + ) + assert accepted.status_code == 201, accepted.text + assert accepted.json()["role"] == "contributor" + + # A contributor may read but not invite. + assert ( + client.get( + f"/v1/workspaces/{workspace_id}", headers=_bearer("bob") + ).status_code + == 200 + ) + forbidden = client.post( + f"/v1/workspaces/{workspace_id}/invitations", + json={"invited_user_id": bob_id, "role": "viewer"}, + headers=_bearer("bob"), + ) + assert forbidden.status_code == 403 + assert forbidden.headers["content-type"] == "application/problem+json" diff --git a/services/work-management/tests/test_workspace_management_integration.py b/services/work-management/tests/test_workspace_management_integration.py new file mode 100644 index 0000000..ac1fb4b --- /dev/null +++ b/services/work-management/tests/test_workspace_management_integration.py @@ -0,0 +1,398 @@ +"""PostgreSQL integration tests for workspace administration and RBAC (Issue #9). + +These cover the security-critical properties: tenant isolation and IDOR/BOLA +denial, owner invariants under concurrency, identity-bound invitation acceptance, +and role-assignment limits. They skip themselves when no test database is +configured. +""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime, timedelta +from threading import Barrier + +import re + +from eventflow_work_management.authorization.policy import Action +from eventflow_work_management.authorization.service import AuthorizationService +from eventflow_work_management.models.membership import ( + WorkspaceInvitation, + WorkspaceMembership, +) +from eventflow_work_management.security.crypto import digest_token +from eventflow_work_management.security.errors import ProblemError +from eventflow_work_management.security.principal import AuthenticatedPrincipal +from eventflow_work_management.security.repository import IdentityRepository +from eventflow_work_management.services.workspace_management import ( + InvitationService, + MembershipService, + ProjectService, + WorkspaceService, +) +from sqlalchemy import select +from support import require_database_url, session_scope, temporary_database + +ISSUER = "https://id.example.test/realms/eventflow" + + +def _identity(session, subject: str): + identity, _ = IdentityRepository(session).resolve_or_create( + issuer=ISSUER, subject=subject + ) + session.commit() + return identity + + +def _principal(identity) -> AuthenticatedPrincipal: + return AuthenticatedPrincipal( + user_id=identity.id, issuer=identity.issuer, subject=identity.subject + ) + + +def _expect_status(status: int, call) -> ProblemError: + try: + call() + except ProblemError as exc: + assert exc.status_code == status, f"expected {status}, got {exc.status_code}" + return exc + raise AssertionError(f"expected ProblemError {status}") + + +def _seed_workspace(session, owner_subject: str = "owner-1"): + owner = _identity(session, owner_subject) + workspace = WorkspaceService(session).create( + _principal(owner), name="Acme Inc", slug=f"acme-{owner.id.hex[:8]}" + ) + return owner, workspace + + +def test_creator_becomes_owner_and_non_member_sees_404(): + with temporary_database(require_database_url()) as (_, engine): + with session_scope(engine) as session: + owner, workspace = _seed_workspace(session) + outsider = _identity(session, "outsider-1") + owners = session.execute( + select(WorkspaceMembership).where( + WorkspaceMembership.workspace_id == workspace.id + ) + ).scalars().all() + assert len(owners) == 1 + assert owners[0].role == "owner" + assert owners[0].user_id == owner.id + + with session_scope(engine) as session: + _expect_status( + 404, + lambda: AuthorizationService(session).authorize( + _principal(outsider), workspace.id, Action.WORKSPACE_READ + ), + ) + + +def test_foreign_membership_id_is_not_found_across_workspaces(): + with temporary_database(require_database_url()) as (_, engine): + with session_scope(engine) as session: + owner, workspace_a = _seed_workspace(session, "owner-a") + _, workspace_b = _seed_workspace(session, "owner-b") + target = session.execute( + select(WorkspaceMembership).where( + WorkspaceMembership.workspace_id == workspace_b.id + ) + ).scalar_one() + target_id = target.id + + with session_scope(engine) as session: + context = AuthorizationService(session).authorize( + _principal(owner), workspace_a.id, Action.MEMBERSHIP_UPDATE_ROLE + ) + # The same membership id under workspace A's path must not be visible. + _expect_status( + 404, + lambda: MembershipService(session).change_role( + context, target_id, role="viewer" + ), + ) + + +def test_invitation_acceptance_is_identity_bound_and_token_digested(): + with temporary_database(require_database_url()) as (_, engine): + with session_scope(engine) as session: + owner, workspace = _seed_workspace(session) + invitee = _identity(session, "invitee-1") + wrong = _identity(session, "wrong-user") + owner_ctx = AuthorizationService(session).authorize( + _principal(owner), workspace.id, Action.MEMBERSHIP_INVITE + ) + created = InvitationService(session).create( + owner_ctx, invited_user_id=invitee.id, role="contributor" + ) + token = created.token + invitation_id = created.invitation.id + stored = session.get(WorkspaceInvitation, invitation_id) + assert stored.token_digest == digest_token(token) + assert token not in stored.token_digest + + with session_scope(engine) as session: + # Wrong identity cannot accept even with the correct token. + _expect_status( + 404, + lambda: InvitationService(session).accept( + _principal(wrong), + workspace_id=workspace.id, + invitation_id=invitation_id, + token=token, + ), + ) + + with session_scope(engine) as session: + membership = InvitationService(session).accept( + _principal(invitee), + workspace_id=workspace.id, + invitation_id=invitation_id, + token=token, + ) + assert membership.role == "contributor" + assert membership.state == "active" + assert session.get(WorkspaceInvitation, invitation_id).state == "accepted" + + with session_scope(engine) as session: + # The token cannot be replayed. + _expect_status( + 404, + lambda: InvitationService(session).accept( + _principal(invitee), + workspace_id=workspace.id, + invitation_id=invitation_id, + token=token, + ), + ) + + +def test_expired_invitation_is_rejected(): + with temporary_database(require_database_url()) as (_, engine): + with session_scope(engine) as session: + owner, workspace = _seed_workspace(session) + invitee = _identity(session, "expiring-invitee") + owner_ctx = AuthorizationService(session).authorize( + _principal(owner), workspace.id, Action.MEMBERSHIP_INVITE + ) + created = InvitationService(session).create( + owner_ctx, invited_user_id=invitee.id, role="viewer" + ) + stored = session.get(WorkspaceInvitation, created.invitation.id) + stored.expires_at = datetime.now(UTC) - timedelta(seconds=1) + session.commit() + invitation_id = stored.id + token = created.token + + with session_scope(engine) as session: + _expect_status( + 409, + lambda: InvitationService(session).accept( + _principal(invitee), + workspace_id=workspace.id, + invitation_id=invitation_id, + token=token, + ), + ) + + +def test_admin_cannot_touch_the_owner_seat_but_owner_can(): + with temporary_database(require_database_url()) as (_, engine): + with session_scope(engine) as session: + owner, workspace = _seed_workspace(session) + admin = _identity(session, "admin-1") + owner_ctx = AuthorizationService(session).authorize( + _principal(owner), workspace.id, Action.MEMBERSHIP_INVITE + ) + created = InvitationService(session).create( + owner_ctx, invited_user_id=admin.id, role="admin" + ) + InvitationService(session).accept( + _principal(admin), + workspace_id=workspace.id, + invitation_id=created.invitation.id, + token=created.token, + ) + owner_membership_id = session.execute( + select(WorkspaceMembership).where( + WorkspaceMembership.workspace_id == workspace.id, + WorkspaceMembership.user_id == owner.id, + ) + ).scalar_one().id + + with session_scope(engine) as session: + admin_ctx = AuthorizationService(session).authorize( + _principal(admin), workspace.id, Action.MEMBERSHIP_UPDATE_ROLE + ) + _expect_status( + 403, + lambda: MembershipService(session).change_role( + admin_ctx, owner_membership_id, role="viewer" + ), + ) + _expect_status( + 403, + lambda: MembershipService(session).change_role( + admin_ctx, owner_membership_id, role="owner" + ), + ) + + +def test_last_owner_cannot_be_demoted_or_revoked(): + with temporary_database(require_database_url()) as (_, engine): + with session_scope(engine) as session: + owner, workspace = _seed_workspace(session) + owner_ctx = AuthorizationService(session).authorize( + _principal(owner), workspace.id, Action.MEMBERSHIP_UPDATE_ROLE + ) + owner_membership_id = owner_ctx.membership.id + + with session_scope(engine) as session: + ctx = AuthorizationService(session).authorize( + _principal(owner), workspace.id, Action.MEMBERSHIP_UPDATE_ROLE + ) + _expect_status( + 409, + lambda: MembershipService(session).change_role( + ctx, owner_membership_id, role="admin" + ), + ) + with session_scope(engine) as session: + ctx = AuthorizationService(session).authorize( + _principal(owner), workspace.id, Action.MEMBERSHIP_REVOKE + ) + _expect_status( + 409, + lambda: MembershipService(session).revoke( + ctx, owner_membership_id, reason="attempt" + ), + ) + + +def test_transfer_ownership_preserves_an_owner(): + with temporary_database(require_database_url()) as (_, engine): + with session_scope(engine) as session: + owner, workspace = _seed_workspace(session) + successor = _identity(session, "successor-1") + owner_ctx = AuthorizationService(session).authorize( + _principal(owner), workspace.id, Action.MEMBERSHIP_INVITE + ) + created = InvitationService(session).create( + owner_ctx, invited_user_id=successor.id, role="contributor" + ) + InvitationService(session).accept( + _principal(successor), + workspace_id=workspace.id, + invitation_id=created.invitation.id, + token=created.token, + ) + successor_membership_id = session.execute( + select(WorkspaceMembership).where( + WorkspaceMembership.workspace_id == workspace.id, + WorkspaceMembership.user_id == successor.id, + ) + ).scalar_one().id + owner_membership_id = owner_ctx.membership.id + + with session_scope(engine) as session: + ctx = AuthorizationService(session).authorize( + _principal(owner), workspace.id, Action.MEMBERSHIP_TRANSFER_OWNERSHIP + ) + target, actor = MembershipService(session).transfer_ownership( + ctx, successor_membership_id + ) + assert target.role == "owner" + assert actor.role == "admin" + + with session_scope(engine) as session: + owners = session.execute( + select(WorkspaceMembership).where( + WorkspaceMembership.workspace_id == workspace.id, + WorkspaceMembership.role == "owner", + WorkspaceMembership.state == "active", + ) + ).scalars().all() + assert len(owners) == 1 + assert owners[0].id == successor_membership_id + assert session.get(WorkspaceMembership, owner_membership_id).role == "admin" + + +def test_viewer_cannot_create_projects_but_can_read(): + with temporary_database(require_database_url()) as (_, engine): + with session_scope(engine) as session: + owner, workspace = _seed_workspace(session) + viewer = _identity(session, "viewer-1") + owner_ctx = AuthorizationService(session).authorize( + _principal(owner), workspace.id, Action.MEMBERSHIP_INVITE + ) + created = InvitationService(session).create( + owner_ctx, invited_user_id=viewer.id, role="viewer" + ) + InvitationService(session).accept( + _principal(viewer), + workspace_id=workspace.id, + invitation_id=created.invitation.id, + token=created.token, + ) + + with session_scope(engine) as session: + _expect_status( + 403, + lambda: AuthorizationService(session).authorize( + _principal(viewer), workspace.id, Action.PROJECT_CREATE + ), + ) + context = AuthorizationService(session).authorize( + _principal(viewer), workspace.id, Action.PROJECT_READ + ) + assert ProjectService(session).list(context) == [] + + +def test_concurrent_workspace_creation_with_same_slug_creates_one(): + admin_url = require_database_url() + with temporary_database(admin_url) as (_, engine): + with session_scope(engine) as session: + identity = _identity(session, "concurrent-creator") + barrier = Barrier(2) + + def create(): + with session_scope(engine) as session: + barrier.wait(timeout=10) + try: + return WorkspaceService(session).create( + _principal(identity), name="Race Co", slug="race-co" + ).id + except ProblemError as exc: + return exc.status_code + + with ThreadPoolExecutor(max_workers=2) as executor: + results = list(executor.map(lambda _: create(), range(2))) + successes = [result for result in results if not isinstance(result, int)] + conflicts = [result for result in results if result == 409] + assert len(successes) == 1 + assert len(conflicts) == 1 + + +def test_invitation_token_is_not_disclosed_after_creation(): + with temporary_database(require_database_url()) as (_, engine): + with session_scope(engine) as session: + owner, workspace = _seed_workspace(session) + invitee = _identity(session, "list-invitee") + owner_ctx = AuthorizationService(session).authorize( + _principal(owner), workspace.id, Action.MEMBERSHIP_INVITE + ) + InvitationService(session).create( + owner_ctx, invited_user_id=invitee.id, role="viewer" + ) + + with session_scope(engine) as session: + invitation = session.execute( + select(WorkspaceInvitation).where( + WorkspaceInvitation.workspace_id == workspace.id + ) + ).scalar_one() + # The ORM model has no plaintext-token attribute at all. + assert not hasattr(invitation, "token") + assert re.fullmatch(r"[0-9a-f]{64}", invitation.token_digest)