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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
744 changes: 744 additions & 0 deletions contracts/openapi/work-management.yaml

Large diffs are not rendered by default.

193 changes: 193 additions & 0 deletions contracts/schemas/work_management.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: >-
Expand Down
39 changes: 37 additions & 2 deletions docs/architecture/work-management-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 6 additions & 3 deletions docs/security/tenant-isolation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |
Expand Down
16 changes: 14 additions & 2 deletions services/work-management/src/eventflow_work_management/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,18 @@
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
from eventflow_work_management.security.crypto import TokenCipher
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
Expand Down Expand Up @@ -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,
)
Expand All @@ -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):
Expand Down Expand Up @@ -170,5 +181,6 @@ def ready():
return {"status": "ready"}

app.include_router(authentication_router)
app.include_router(workspace_router)

return app
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from eventflow_work_management.security.errors import (
AuthenticationDependencyError,
AuthenticationError,
ProblemError,
)
from eventflow_work_management.security.oidc import OIDCError, OIDCProviderUnavailable

Expand Down Expand Up @@ -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
):
Expand Down
Loading
Loading