From b0492922904157ed4b090483083a65ee24418c87 Mon Sep 17 00:00:00 2001 From: athulrajtflycatchtech Date: Fri, 14 Aug 2026 18:52:38 +0530 Subject: [PATCH 1/3] feat(auth-rbac): add comprehensive data model, implementation plan, quickstart guide, and research documentation for authentication and authorization using JWT and RBAC --- .../002-auth-rbac/checklists/requirements.md | 40 +++ specs/002-auth-rbac/contracts/README.md | 44 ++++ .../contracts/admin-auth.v2.yaml | 241 ++++++++++++++++++ .../contracts/admin-management.v2.yaml | 167 ++++++++++++ .../contracts/admin-rbac.v1.yaml | 94 +++++++ .../contracts/bootstrap.cli.yaml | 60 +++++ specs/002-auth-rbac/contracts/publish.v2.yaml | 67 +++++ specs/002-auth-rbac/data-model.md | 216 ++++++++++++++++ specs/002-auth-rbac/plan.md | 135 ++++++++++ specs/002-auth-rbac/quickstart.md | 103 ++++++++ specs/002-auth-rbac/research.md | 186 ++++++++++++++ specs/002-auth-rbac/spec.md | 217 ++++++++++++++++ 12 files changed, 1570 insertions(+) create mode 100644 specs/002-auth-rbac/checklists/requirements.md create mode 100644 specs/002-auth-rbac/contracts/README.md create mode 100644 specs/002-auth-rbac/contracts/admin-auth.v2.yaml create mode 100644 specs/002-auth-rbac/contracts/admin-management.v2.yaml create mode 100644 specs/002-auth-rbac/contracts/admin-rbac.v1.yaml create mode 100644 specs/002-auth-rbac/contracts/bootstrap.cli.yaml create mode 100644 specs/002-auth-rbac/contracts/publish.v2.yaml create mode 100644 specs/002-auth-rbac/data-model.md create mode 100644 specs/002-auth-rbac/plan.md create mode 100644 specs/002-auth-rbac/quickstart.md create mode 100644 specs/002-auth-rbac/research.md create mode 100644 specs/002-auth-rbac/spec.md diff --git a/specs/002-auth-rbac/checklists/requirements.md b/specs/002-auth-rbac/checklists/requirements.md new file mode 100644 index 0000000..471c72e --- /dev/null +++ b/specs/002-auth-rbac/checklists/requirements.md @@ -0,0 +1,40 @@ +# Specification Quality Checklist: Authentication and Authorisation (RBAC) + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-08-14 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Validation iteration 1 (2026-08-14): All items pass. +- No `[NEEDS CLARIFICATION]` markers in spec.md. +- Informed defaults (documented in Assumptions and FR-017–FR-019): password sign-in only; no self-service sign-up; default roles Administrator (all permissions) and Editor (view + draft); bootstrap creates at least two staff users; at least one is Administrator; if the operator does not choose otherwise both receive Administrator; no user-management UI in this phase; operator provisioning path assigns roles to later users. +- Implementation avoided: no languages, frameworks, data stores, or vendor identity products. Enforcement is described as “server-side / backend must refuse” so UI hiding is not treated as sufficient (security requirement, not a stack choice). +- Extension points named without prescribing stack: existing Administration UI, existing staff session boundary, existing operator provisioning path, existing administration sign-in contract (may be extended with roles and permissions). +- Stack, tooling, and exact contract file versions remain deferred to `/speckit-plan`. +- Items marked incomplete would require spec updates before `/speckit-clarify` or `/speckit-plan`; none remain incomplete. diff --git a/specs/002-auth-rbac/contracts/README.md b/specs/002-auth-rbac/contracts/README.md new file mode 100644 index 0000000..77aa000 --- /dev/null +++ b/specs/002-auth-rbac/contracts/README.md @@ -0,0 +1,44 @@ +# Contracts: Authentication and Authorisation (RBAC) + +OpenAPI 3.1 documents in this directory are the **single source of truth** for FR-028. Backend and Administration FE MUST consume and match these files. They **supersede** foundation `admin-auth.v1` cookie sessions and CSRF on management/publish. + +Payload schemas for pages and site settings remain in `specs/001-website-foundation/contracts/` and are referenced, not copied. + +## Rules + +1. **Publish contracts first** — no consumer implementation before the YAML exists and passes validation. +2. **Backend implements** — routers and Pydantic models align with these files; served `/openapi.json` MUST not drift. +3. **Administration FE matches** — runtime API client and types MUST be generated from `admin-auth.v2`, `admin-rbac.v1`, `admin-management.v2`, and `publish.v2`. +4. **Breaking changes** — require a new version file; quality gates reject silent drift. +5. **Tokens** — access JWT and refresh token appear only in documented JSON bodies. Clients store them in memory and send the access token as `Authorization: Bearer`. + +Runtime prefix: `/api/v1`. Public browsers MUST NOT call these endpoints for ordinary page views. + +## Boundaries + +| File | Boundary | Notes | +| --- | --- | --- | +| [admin-auth.v2.yaml](./admin-auth.v2.yaml) | Sign-in, refresh, sign-out, session | Replaces `001` `admin-auth.v1.yaml` | +| [admin-rbac.v1.yaml](./admin-rbac.v1.yaml) | Roles, permissions, denial shape | Shared schemas + operation map | +| [admin-management.v2.yaml](./admin-management.v2.yaml) | View/draft managed records | Bearer; no CSRF; 401 vs 403 | +| [publish.v2.yaml](./publish.v2.yaml) | Publish | Bearer; `records.publish` required | +| [bootstrap.cli.yaml](./bootstrap.cli.yaml) | Operator bootstrap CLI | Not an HTTP API | + +## Consumer map + +| Contract | Backend | Frontend | Administration FE | +| --- | --- | --- | --- | +| admin-auth.v2 | Implements routes | — | Generated client + in-memory token store | +| admin-rbac.v1 | Implements checks | — | Session permissions / control visibility | +| admin-management.v2 | Implements routes | — | Generated client | +| publish.v2 | Implements route | Snapshot still from `001` publish.v1 schemas | Generated client | +| bootstrap.cli | Implements CLI | — | — | + +## Superseded foundation pieces + +| Foundation contract | Status in this feature | +| --- | --- | +| `admin-auth.v1.yaml` | Replaced by `admin-auth.v2.yaml` | +| Cookie `admin_session` + `GET /admin/csrf` | Removed | +| `admin-management.v1.yaml` security/CSRF | Replaced by `admin-management.v2.yaml` | +| `publish.v1.yaml` cookie/CSRF | Replaced by `publish.v2.yaml` (snapshot schema unchanged) | diff --git a/specs/002-auth-rbac/contracts/admin-auth.v2.yaml b/specs/002-auth-rbac/contracts/admin-auth.v2.yaml new file mode 100644 index 0000000..d047d00 --- /dev/null +++ b/specs/002-auth-rbac/contracts/admin-auth.v2.yaml @@ -0,0 +1,241 @@ +openapi: 3.1.0 +info: + title: Administration Sign-In (JWT) + version: 2.0.0 + summary: JWT access token plus required refresh token for provisioned administrators. + description: | + No self-registration. Failed sign-in MUST use a generic error and MUST NOT + disclose whether the account exists. Sign-in and refresh ALWAYS return both + an access JWT and a refresh token. Clients MUST store both in memory only + and MUST send the access token as Authorization: Bearer. Refresh tokens are + hashed server-side (idle 30 minutes, absolute 12 hours). Access JWT lifetime + is 15 minutes and MUST NOT contain roles or permissions. +servers: + - url: /api/v1 +paths: + /admin/auth/sign-in: + post: + operationId: adminSignIn + summary: Authenticate and issue access plus refresh tokens + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SignInRequest' + responses: + '200': + description: Signed in; both tokens returned in the body + content: + application/json: + schema: + $ref: '#/components/schemas/TokenPair' + '401': + description: Invalid, unknown, or inactive credentials (generic) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + '422': + description: Field-level validation errors + content: + application/json: + schema: + $ref: '#/components/schemas/FieldErrors' + /admin/auth/refresh: + post: + operationId: adminRefresh + summary: Rotate the refresh token and issue a new access JWT + description: | + Refresh is required. The previous refresh token is revoked. Reuse of a + revoked refresh token in the same family MUST revoke the family. + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RefreshRequest' + responses: + '200': + description: New token pair + content: + application/json: + schema: + $ref: '#/components/schemas/TokenPair' + '401': + description: Missing, expired, idle-timed-out, revoked, or reused refresh token + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + '422': + description: Field-level validation errors + content: + application/json: + schema: + $ref: '#/components/schemas/FieldErrors' + /admin/auth/sign-out: + post: + operationId: adminSignOut + summary: Revoke the current refresh session + description: | + Prefer Authorization: Bearer (access JWT `sid`). If the access token is + already expired, the client MUST send refresh_token in the body so the + session can still be revoked. + security: + - bearerAuth: [] + - {} + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/SignOutRequest' + responses: + '204': + description: Signed out; refresh session revoked + '401': + description: Not signed in and no valid refresh token supplied + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + /admin/auth/session: + get: + operationId: adminSession + summary: Return current identity, roles, and permissions + description: | + Roles and permissions are loaded from current assignments, not from JWT + claims. Inactive users MUST be treated as unauthenticated. + security: + - bearerAuth: [] + responses: + '200': + description: Active session context + content: + application/json: + schema: + $ref: '#/components/schemas/SessionContext' + '401': + description: Missing, expired, invalid, or inactive + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: Access JWT from sign-in or refresh. Not a cookie. + schemas: + SignInRequest: + type: object + additionalProperties: false + required: [email, password] + properties: + email: + type: string + format: email + password: + type: string + minLength: 1 + writeOnly: true + RefreshRequest: + type: object + additionalProperties: false + required: [refresh_token] + properties: + refresh_token: + type: string + minLength: 1 + writeOnly: true + SignOutRequest: + type: object + additionalProperties: false + properties: + refresh_token: + type: string + minLength: 1 + writeOnly: true + description: Required when the access token is missing or expired + TokenPair: + type: object + additionalProperties: false + required: [access_token, refresh_token, token_type, expires_in, session] + properties: + access_token: + type: string + minLength: 1 + refresh_token: + type: string + minLength: 1 + token_type: + type: string + const: bearer + expires_in: + type: integer + minimum: 1 + description: Access token lifetime in seconds (900) + session: + $ref: '#/components/schemas/SessionContext' + SessionContext: + type: object + additionalProperties: false + required: + - administrator_id + - email + - roles + - permissions + - idle_expires_at + - absolute_expires_at + properties: + administrator_id: + type: string + format: uuid + email: + type: string + format: email + roles: + type: array + uniqueItems: true + items: + $ref: './admin-rbac.v1.yaml#/components/schemas/RoleName' + permissions: + type: array + uniqueItems: true + items: + $ref: './admin-rbac.v1.yaml#/components/schemas/PermissionName' + idle_expires_at: + type: string + format: date-time + absolute_expires_at: + type: string + format: date-time + AuthError: + type: object + additionalProperties: false + required: [code, message_key] + properties: + code: + type: string + enum: [unauthenticated, invalid_credentials] + message_key: + type: string + description: i18n key; same key for unknown, wrong password, and inactive + FieldErrors: + type: object + additionalProperties: false + required: [fields] + properties: + fields: + type: object + additionalProperties: + type: object + required: [message_key] + properties: + message_key: + type: string diff --git a/specs/002-auth-rbac/contracts/admin-management.v2.yaml b/specs/002-auth-rbac/contracts/admin-management.v2.yaml new file mode 100644 index 0000000..cc063c8 --- /dev/null +++ b/specs/002-auth-rbac/contracts/admin-management.v2.yaml @@ -0,0 +1,167 @@ +openapi: 3.1.0 +info: + title: Administration Content and Settings Management + version: 2.0.0 + summary: View and edit draft payloads for managed records. + description: | + Draft saves MUST NOT change the public static site. Publish is a separate + boundary (publish.v2.yaml). Authentication is Authorization: Bearer (access + JWT). CSRF is not used. GET requires records.view; PATCH requires drafts.save. + Unauthenticated requests MUST be 401; missing permission MUST be 403. +servers: + - url: /api/v1 +security: + - bearerAuth: [] +paths: + /admin/site-settings: + get: + operationId: getSiteSettingsRecord + summary: Return draft and published site settings + responses: + '200': + description: Site settings record + content: + application/json: + schema: + $ref: '#/components/schemas/ManagedSiteSettings' + '401': + $ref: './admin-rbac.v1.yaml#/components/responses/Unauthenticated' + '403': + $ref: './admin-rbac.v1.yaml#/components/responses/PermissionDenied' + patch: + operationId: saveSiteSettingsDraft + summary: Save site-settings draft + requestBody: + required: true + content: + application/json: + schema: + $ref: '../../001-website-foundation/contracts/site-settings.v1.yaml#/components/schemas/SiteSettings' + responses: + '200': + description: Site settings record + content: + application/json: + schema: + $ref: '#/components/schemas/ManagedSiteSettings' + '401': + $ref: './admin-rbac.v1.yaml#/components/responses/Unauthenticated' + '403': + $ref: './admin-rbac.v1.yaml#/components/responses/PermissionDenied' + '422': + $ref: '#/components/responses/FieldErrors' + /admin/pages/{slug}: + get: + operationId: getPageRecord + summary: Return draft and published page + parameters: + - $ref: '#/components/parameters/Slug' + responses: + '200': + description: Page record + content: + application/json: + schema: + $ref: '#/components/schemas/ManagedPage' + '401': + $ref: './admin-rbac.v1.yaml#/components/responses/Unauthenticated' + '403': + $ref: './admin-rbac.v1.yaml#/components/responses/PermissionDenied' + '404': + description: Unknown slug + patch: + operationId: savePageDraft + summary: Save page draft + parameters: + - $ref: '#/components/parameters/Slug' + requestBody: + required: true + content: + application/json: + schema: + $ref: '../../001-website-foundation/contracts/content.v1.yaml#/components/schemas/PageContent' + responses: + '200': + description: Page record + content: + application/json: + schema: + $ref: '#/components/schemas/ManagedPage' + '401': + $ref: './admin-rbac.v1.yaml#/components/responses/Unauthenticated' + '403': + $ref: './admin-rbac.v1.yaml#/components/responses/PermissionDenied' + '404': + description: Unknown slug + '422': + $ref: '#/components/responses/FieldErrors' +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + parameters: + Slug: + name: slug + in: path + required: true + schema: + type: string + examples: [home] + responses: + FieldErrors: + description: Accessible field-level validation errors + content: + application/json: + schema: + $ref: './admin-auth.v2.yaml#/components/schemas/FieldErrors' + schemas: + Attribution: + type: object + additionalProperties: false + required: [at, by] + properties: + at: + type: string + format: date-time + by: + type: string + format: uuid + ManagedSiteSettings: + type: object + additionalProperties: false + required: [type, slug, draft, draft_updated] + properties: + type: + type: string + const: site_settings + slug: + type: string + const: default + draft: + $ref: '../../001-website-foundation/contracts/site-settings.v1.yaml#/components/schemas/SiteSettings' + published: + $ref: '../../001-website-foundation/contracts/site-settings.v1.yaml#/components/schemas/SiteSettings' + draft_updated: + $ref: '#/components/schemas/Attribution' + published_meta: + $ref: '#/components/schemas/Attribution' + ManagedPage: + type: object + additionalProperties: false + required: [type, slug, draft, draft_updated] + properties: + type: + type: string + const: page + slug: + type: string + draft: + $ref: '../../001-website-foundation/contracts/content.v1.yaml#/components/schemas/PageContent' + published: + $ref: '../../001-website-foundation/contracts/content.v1.yaml#/components/schemas/PageContent' + draft_updated: + $ref: '#/components/schemas/Attribution' + published_meta: + $ref: '#/components/schemas/Attribution' diff --git a/specs/002-auth-rbac/contracts/admin-rbac.v1.yaml b/specs/002-auth-rbac/contracts/admin-rbac.v1.yaml new file mode 100644 index 0000000..8ebeaf1 --- /dev/null +++ b/specs/002-auth-rbac/contracts/admin-rbac.v1.yaml @@ -0,0 +1,94 @@ +openapi: 3.1.0 +info: + title: Administration RBAC + version: 1.0.0 + summary: Role and permission catalogue plus permission-denied outcome. + description: | + A Role is a named set of Permissions. Effective permissions are the union + of a user's assigned roles. Enforcement is server-side at request time. + This document is the catalogue and denial contract; it does not expose + user or role management HTTP APIs. +servers: + - url: /api/v1 +paths: {} +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + schemas: + RoleName: + type: string + enum: [administrator, editor] + PermissionName: + type: string + enum: [records.view, drafts.save, records.publish] + PermissionDenied: + type: object + additionalProperties: false + required: [code, message_key, permission] + properties: + code: + type: string + const: permission_denied + message_key: + type: string + const: admin.action.forbidden + permission: + $ref: '#/components/schemas/PermissionName' + RoleGrant: + type: object + additionalProperties: false + required: [name, permissions] + properties: + name: + $ref: '#/components/schemas/RoleName' + permissions: + type: array + uniqueItems: true + items: + $ref: '#/components/schemas/PermissionName' + DefaultRoleCatalogue: + type: array + minItems: 2 + items: + $ref: '#/components/schemas/RoleGrant' + example: + - name: administrator + permissions: [records.view, drafts.save, records.publish] + - name: editor + permissions: [records.view, drafts.save] + OperationPermissionMap: + type: object + additionalProperties: false + description: Required permission for each protected administration action + properties: + getSiteSettingsRecord: + type: string + const: records.view + getPageRecord: + type: string + const: records.view + saveSiteSettingsDraft: + type: string + const: drafts.save + savePageDraft: + type: string + const: drafts.save + publishRecord: + type: string + const: records.publish + responses: + Unauthenticated: + description: Missing, invalid, expired, or inactive — not a permission denial + content: + application/json: + schema: + $ref: './admin-auth.v2.yaml#/components/schemas/AuthError' + PermissionDenied: + description: Signed in but lacking the required permission; session remains valid + content: + application/json: + schema: + $ref: '#/components/schemas/PermissionDenied' diff --git a/specs/002-auth-rbac/contracts/bootstrap.cli.yaml b/specs/002-auth-rbac/contracts/bootstrap.cli.yaml new file mode 100644 index 0000000..e667455 --- /dev/null +++ b/specs/002-auth-rbac/contracts/bootstrap.cli.yaml @@ -0,0 +1,60 @@ +# Operator bootstrap CLI (not an HTTP API) +# Feature: 002-auth-rbac +# Entry point: flycatch-bootstrap + +command: flycatch-bootstrap +summary: Create default roles and at least two administrative users, then assign roles. +idempotent: true +fail_closed: true + +arguments: + --user-1-email: + type: email + required: true + description: First default user. Always assigned role administrator. + --user-1-password: + type: secret + required: true + source: flag or interactive prompt + min_length: 12 + description: MUST NOT be logged or written to public pages. + --user-2-email: + type: email + required: true + description: Second default user. MUST differ from user 1. + --user-2-password: + type: secret + required: true + source: flag or interactive prompt + min_length: 12 + --user-2-role: + type: enum + values: [administrator, editor] + default: administrator + required: false + --created-by: + type: string + default: cli + required: false + +success: + - roles administrator and editor exist with catalogue permissions + - both users exist, are active, and have at least one role + - user 1 has administrator + - stdout confirms identities only (no secrets) + +repeat_run: + - matching role names are left intact + - matching emails are left intact (no duplicate users) + - missing user of the pair is created + - exit reports defaults already exist when nothing changed + +failure: + - missing or invalid required inputs + - no partial default set (no users without roles; no incomplete role catalogue) + - secrets never printed + +related: + flycatch-provision-admin: + change: --role becomes required (administrator | editor) + note: Additional staff after bootstrap; not a sign-up path diff --git a/specs/002-auth-rbac/contracts/publish.v2.yaml b/specs/002-auth-rbac/contracts/publish.v2.yaml new file mode 100644 index 0000000..a9dbb41 --- /dev/null +++ b/specs/002-auth-rbac/contracts/publish.v2.yaml @@ -0,0 +1,67 @@ +openapi: 3.1.0 +info: + title: Publish + version: 2.0.0 + summary: Promote a draft to published and write the public snapshot. + description: | + Publish copies draft_payload to published_payload, writes the published + snapshot to object storage, and is complete only after the documented + static rebuild of apps/Frontend. Draft-only content MUST NOT become a + public URL. Authentication is Authorization: Bearer. The caller MUST have + records.publish. CSRF is not used. Unauthenticated MUST be 401; missing + permission MUST be 403 and MUST NOT change the published site. +servers: + - url: /api/v1 +security: + - bearerAuth: [] +paths: + /admin/publish: + post: + operationId: publishRecord + summary: Publish one managed record + requestBody: + required: true + content: + application/json: + schema: + $ref: '../../001-website-foundation/contracts/publish.v1.yaml#/components/schemas/PublishRequest' + responses: + '200': + description: Record published; snapshot written; rebuild still required + content: + application/json: + schema: + $ref: '../../001-website-foundation/contracts/publish.v1.yaml#/components/schemas/PublishResult' + '401': + $ref: './admin-rbac.v1.yaml#/components/responses/Unauthenticated' + '403': + $ref: './admin-rbac.v1.yaml#/components/responses/PermissionDenied' + '404': + description: Unknown record + '409': + description: Draft failed publish validation (e.g. missing unique metadata) + content: + application/json: + schema: + $ref: '../../001-website-foundation/contracts/publish.v1.yaml#/components/schemas/PublishRejected' + /published/snapshot: + get: + operationId: getPublishedSnapshot + summary: Build-pipeline export of published payloads only + description: Not for ordinary public browsing. Used by export/rebuild. + security: [] + responses: + '200': + description: Published snapshot export + content: + application/json: + schema: + $ref: '../../001-website-foundation/contracts/publish.v1.yaml#/components/schemas/PublishedSnapshot' + '401': + description: Missing build credential when required in preview/production +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT diff --git a/specs/002-auth-rbac/data-model.md b/specs/002-auth-rbac/data-model.md new file mode 100644 index 0000000..8fa4fe3 --- /dev/null +++ b/specs/002-auth-rbac/data-model.md @@ -0,0 +1,216 @@ +# Data Model: Authentication and Authorisation (RBAC) + +**Feature**: `002-auth-rbac` +**Date**: 2026-08-14 +**Source**: [spec.md](./spec.md) Key Entities + FR-001–FR-024 +**Extends**: [001-website-foundation/data-model.md](../001-website-foundation/data-model.md) + +This model adds roles, permissions, and a refresh-token session. It does not add public visitor accounts, a user-management UI, or new content types. + +## Entity relationship + +```text +Administrator 1──* AdministratorRole *──1 Role +Role 1──* RolePermission (permission enum) + +Administrator 1──* RefreshSession (hashed refresh token; access JWT not stored) + +Administrator 1──* ManagedRecord (attribution; unchanged from foundation) +``` + +--- + +## 1. Administrator (extended) + +Provisioned staff identity. Unchanged core fields from the foundation. + +| Field | Type | Rules | +| --- | --- | --- | +| `id` | UUID | Primary key | +| `email` | string | Unique, lowercase, valid email, required | +| `password_hash` | string | Argon2; never returned in any API or HTML | +| `is_active` | boolean | Inactive accounts cannot sign in; existing refresh sessions MUST be treated as signed out | +| `created_at` | datetime (UTC) | Set on provision or bootstrap | +| `created_by` | string | Operator identifier (CLI / env), not a public person | + +**Validation**: Email unique. Password at provision/bootstrap: minimum 12 characters (operator-set). Failed sign-in MUST NOT disclose whether the email exists, whether the user is inactive, or which roles they have. + +**Relationships**: Many `AdministratorRole`; many `RefreshSession`. + +**State**: `active` ↔ `inactive`. Inactive is treated as unknown credentials at sign-in and as unauthenticated on subsequent requests (401, not 403). + +**New rule**: Newly provisioned users MUST be assigned at least one role from the catalogue (FR-023). + +--- + +## 2. Role + +Named set of permissions. Does not sign in. + +| Field | Type | Rules | +| --- | --- | --- | +| `id` | UUID | Primary key | +| `name` | string | Unique, lowercase slug. Catalogue: `administrator`, `editor` | +| `created_at` | datetime (UTC) | | + +**Validation**: `name` unique. Bootstrap MUST create both catalogue names. Repeat bootstrap MUST NOT duplicate. + +**Default catalogue** (FR-010): + +| `name` | Permissions | +| --- | --- | +| `administrator` | `records.view`, `drafts.save`, `records.publish` | +| `editor` | `records.view`, `drafts.save` | + +`editor` MUST NOT include `records.publish`. + +--- + +## 3. Permission (catalogue, not a free table) + +Named capability mapped to one existing administration action. Stored as an enum on `role_permissions.permission`, not as an editable product table. + +| Value | Meaning | Enforced on | +| --- | --- | --- | +| `records.view` | View managed records | `GET /admin/site-settings`, `GET /admin/pages/{slug}` | +| `drafts.save` | Save drafts | `PATCH` draft endpoints | +| `records.publish` | Publish | `POST /admin/publish` | + +No other permission names in this feature. + +--- + +## 4. RolePermission + +Link between a Role and one Permission. + +| Field | Type | Rules | +| --- | --- | --- | +| `role_id` | UUID | FK → Role, part of primary key | +| `permission` | enum | One of the catalogue values; part of primary key | + +**Validation**: Unique `(role_id, permission)`. Bootstrap sets the default grants in FR-010. + +--- + +## 5. AdministratorRole (role assignment) + +Link between a staff user and a Role. + +| Field | Type | Rules | +| --- | --- | --- | +| `administrator_id` | UUID | FK → Administrator, part of primary key | +| `role_id` | UUID | FK → Role, part of primary key | +| `assigned_at` | datetime (UTC) | | +| `assigned_by` | string | Operator identifier | + +**Validation**: Unique `(administrator_id, role_id)`. A user MAY have more than one role. + +**Effective permissions**: Union of all assigned roles’ permissions (FR-009). A user with no roles or only empty roles MAY sign in if active; every protected mutation MUST be denied (403). + +**Request-time rule**: Protected actions MUST load current assignments. A grant captured at sign-in MUST NOT remain authoritative after roles change (FR-015). + +--- + +## 6. RefreshSession (evolves AdminSession) + +Server-backed signed-in period. Replaces the foundation cookie session. The **refresh token** is the secret; the access JWT is not stored. + +| Field | Type | Rules | +| --- | --- | --- | +| `id` | UUID | Primary key; copied into access JWT `sid` | +| `administrator_id` | UUID | FK → Administrator | +| `refresh_token_hash` | string | SHA-256 of `session_secret + refresh_token`; never the raw token | +| `family_id` | UUID | Rotation family; reuse of a revoked token in the family MUST revoke the family | +| `created_at` | datetime (UTC) | Absolute lifetime starts here | +| `last_seen_at` | datetime (UTC) | Updated on successful refresh or authenticated use that extends idle | +| `idle_expires_at` | datetime (UTC) | `last_seen_at` + 30 minutes | +| `absolute_expires_at` | datetime (UTC) | `created_at` + 12 hours | +| `revoked_at` | datetime (UTC), optional | Set on sign-out, rotation (old row), or family revoke | + +**Validation**: A session is valid only when `revoked_at` is null, now < `idle_expires_at`, now < `absolute_expires_at`, and the administrator is active. + +**State transitions**: + +```text +created ──(refresh)──► rotated (old hash revoked; new hash same family_id) +created ──(idle or absolute timeout)──► expired +created ──(sign-out)──────────────────► revoked +rotated/expired/revoked ──► treated as signed out; no auto-publish of unsaved edits +reuse of a revoked refresh in the family ──► family revoked +``` + +**Access JWT** (not a row): HS256, 15-minute `exp`, claims `sub`, `sid`, `typ=access`, `iat`, `exp`, `jti`. MUST NOT contain roles or permissions. + +**Client**: Both tokens live in Administration FE memory only. Access token is sent as `Authorization: Bearer`. Neither token is a cookie. + +--- + +## 7. TokenPair (API shape, not persisted) + +Returned by sign-in and refresh. See [admin-auth.v2.yaml](./contracts/admin-auth.v2.yaml). + +| Field | Type | Rules | +| --- | --- | --- | +| `access_token` | string | JWT; write-once in the response | +| `refresh_token` | string | Opaque; write-once in the response | +| `token_type` | string | Constant `bearer` | +| `expires_in` | integer | Access TTL in seconds (900) | +| `session` | SessionContext | Identity + current roles/permissions + expiries | + +Tokens MUST NOT appear in logs, public HTML, or durable client storage. + +--- + +## 8. SessionContext (API shape) + +Returned on sign-in, refresh, and `GET /admin/auth/session`. Roles and permissions are computed from **current** assignments, not from JWT claims. + +| Field | Type | Rules | +| --- | --- | --- | +| `administrator_id` | UUID | | +| `email` | string | | +| `roles` | string[] | Role `name` values, sorted | +| `permissions` | string[] | Union of assigned permissions, sorted | +| `idle_expires_at` | datetime (UTC) | From the refresh session | +| `absolute_expires_at` | datetime (UTC) | From the refresh session | + +--- + +## 9. PermissionDenied (API shape) + +Used when the caller is authenticated but lacks the required permission. + +| Field | Type | Rules | +| --- | --- | --- | +| `code` | string | `permission_denied` | +| `message_key` | string | `admin.action.forbidden` | +| `permission` | string | The catalogue value that was required | + +Must not be used for missing/invalid/expired tokens or inactive users (those are `AuthError` / 401). + +--- + +## 10. Bootstrap set (operator input, not a table) + +| Input | Rules | +| --- | --- | +| User 1 email + password | Required; password ≥ 12 characters | +| User 2 email + password | Required; distinct email; password ≥ 12 characters | +| User 2 role | Optional; `administrator` (default) or `editor` | +| User 1 role | Always `administrator` (FR-018) | + +**Behaviour**: Create default roles and permissions if missing; create missing users; assign roles; do not duplicate existing matching emails or role names; fail closed if any required input is missing (no users without roles, no partial default role catalogue). Secrets MUST NOT be logged or written to public pages. + +--- + +## Validation rules (cross-cutting) + +- Public export and public HTML never include tokens, password hashes, roles, or staff emails. +- Administration responses never embed password hashes or raw refresh/access tokens except in the documented sign-in/refresh JSON body. +- Unauthenticated protected actions → 401. Insufficient permission → 403. The two MUST NOT be interchangeable. +- Message keys for sign-in and denial live in `apps/Administration-FE/src/i18n/en.json`. + +## Out of model (explicit) + +Self-registration, password recovery, SSO, user/role admin screens, per-record ACLs, public visitor accounts, durable browser token storage, and permission names beyond the three-action catalogue. diff --git a/specs/002-auth-rbac/plan.md b/specs/002-auth-rbac/plan.md new file mode 100644 index 0000000..86c5b8d --- /dev/null +++ b/specs/002-auth-rbac/plan.md @@ -0,0 +1,135 @@ +# Implementation Plan: Authentication and Authorisation (RBAC) + +**Branch**: `002-auth-rbac` | **Date**: 2026-08-14 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `/specs/002-auth-rbac/spec.md` + +**Note**: This template is filled in by the `/speckit-plan` command; its definition describes the execution workflow. + +**Plan input**: Use JWT auth with required refresh tokens. Store both tokens in frontend memory and send the access token via `Authorization: Bearer`. + +## Summary + +Add staff password sign-in with **JWT access tokens and required refresh tokens**, plus minimal RBAC (Administrator / Editor over view, draft, and publish) and an operator bootstrap that creates those roles and at least two users. The Administration FE keeps both tokens in **memory only** and sends the access token as `Authorization: Bearer`. The Backend validates the JWT, then evaluates **current** role assignments on every protected action. Foundation cookie sessions and CSRF synchronizer tokens are superseded. Public static delivery is unchanged. + +## Technical Context + +**Language/Version**: TypeScript 5.x (Astro 5, React 19) for `apps/Administration-FE`; Python 3.12 for `apps/Backend`. `apps/Frontend` is out of change scope except non-regression. + +**Primary Dependencies**: Existing foundation stack plus **PyJWT** (HS256) on the Backend. Argon2 remains for passwords. Administration FE uses an OpenAPI-generated client from this feature’s contracts. No Redis, no UI kit, no new public JS. + +**Storage**: PostgreSQL 16 — extend with `roles`, `role_permissions`, `administrator_roles`; evolve `admin_sessions` into the hashed **refresh-token session** (idle 30 minutes, absolute 12 hours, rotation). Access JWTs are not persisted. + +**Testing**: pytest + HTTPX (unit/integration/contract), Vitest, Playwright (admin sign-in, grant, deny including direct publish), `@axe-core/playwright` (WCAG 2.2 AA), openapi-spec-validator, i18n hard-coded-string scan. Public Playwright/Lighthouse gates remain non-regression. + +**Target Platform**: Same as foundation — Administration FE + Backend behind one HTTPS origin (`/admin`, `/api`); public site static. + +**Project Type**: Multi-surface web system (extend Backend + Administration FE only) + +**Performance Goals**: Public foundation budgets unchanged (0 KiB JS, LCP ≤ 2.5s, INP ≤ 200ms, CLS ≤ 0.1). Administration: sign-in on a typical office connection in under 30 seconds (SC-002); no new public payload. + +**Constraints**: No self-registration; no durable browser storage of tokens; no permissions in JWT claims; no well-known bootstrap passwords; Editor MUST NOT receive publish; Bearer-only admin API (no session cookie); fail-closed bootstrap; Conventional Commits. + +**Scale/Scope**: Small provisioned staff set; two default roles; three permissions mapped to existing admin actions; one bootstrap CLI; no user-management UI. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Gate | Status | +| --- | --- | --- | +| I. SEO and AEO First | Administration remains `noindex` and absent from the public sitemap; no admin links on anonymous public pages | PASS | +| II. Native Elements First | Sign-in stays a native form; no UI kit; in-memory token store is required by Bearer transport, not a parallel widget library | PASS | +| III. Contract-First | OpenAPI 3.1 in `specs/002-auth-rbac/contracts/` published before consumers change; breaking auth/management/publish get v2 files | PASS | +| IV. Conventional Commits | Unchanged project rule | PASS | +| V. Internationalisation | Sign-in, validation, and denial use message keys only (`en` catalogue) | PASS | +| VI. Performance by Default | Zero new public JS; admin adds no third-party UI runtime | PASS | +| VII. Core Web Vitals | Public targets unchanged; admin exempt from ranking vitals (foundation FR-044) | PASS | +| VIII. Security by Default | Argon2; generic auth errors; hashed refresh; short-lived JWT; request-time RBAC; secrets not in HTML/logs/durable client storage; Bearer not auto-sent cross-site | PASS | +| IX. Accessibility | WCAG 2.2 AA on sign-in and denial; labels, field errors, keyboard, focus | PASS | +| X. Design Consistency | Reuse Administration layout regions and existing controls | PASS | +| XI. Responsive UI | Existing admin shell remains usable across viewports | PASS | +| XII. Production-Grade | Refresh rotation, fail-closed bootstrap, contract tests, operator CLI | PASS | +| XIII. Quality Gates | Bootstrap, sign-in success/generic failure, grant, deny (including direct request), contract parity | PASS | + +No unjustified violations. JWT + refresh is the mandated session design, not an extra product surface. + +### Post-design re-check + +Phase 1 artifacts (`research.md`, `data-model.md`, `contracts/*`, `quickstart.md`) stay inside the spec: staff-only password auth, no sign-up, two default roles, three permissions, operator bootstrap, request-time enforcement. Public contracts and Frontend templates are not rewritten. Gates above remain PASS. + +## Contract consumption + +OpenAPI files in `specs/002-auth-rbac/contracts/` are the **single source of truth** for this feature’s cross-boundary shapes. Foundation payload schemas (`content.v1`, `site-settings.v1`, `seo-metadata.v1`) remain in `specs/001-website-foundation/contracts/` and are referenced, not duplicated. + +| Consumer | How it MUST match the contract | +| --- | --- | +| **Backend** | Auth, management, and publish routers implement the v2/v1 files in this directory; served `/openapi.json` MUST align; cookie `admin_session` and CSRF MUST NOT remain the staff auth mechanism | +| **Administration FE** | API client and TypeScript types MUST be generated from `admin-auth.v2`, `admin-rbac.v1`, `admin-management.v2`, and `publish.v2` — no hand-written token or permission DTOs | +| **Frontend** | No new runtime contract; public snapshot contracts unchanged | + +Quality gates MUST reject consumer changes that do not trace to the same contract revision the Backend implements. + +## Project Structure + +### Documentation (this feature) + +```text +specs/002-auth-rbac/ +├── plan.md # This file (/speckit-plan command output) +├── research.md # Phase 0 output (/speckit-plan command) +├── data-model.md # Phase 1 output (/speckit-plan command) +├── quickstart.md # Phase 1 output (/speckit-plan command) +├── contracts/ # Phase 1 output — OpenAPI + bootstrap CLI +└── tasks.md # Phase 2 output (/speckit-tasks command - NOT created by /speckit-plan) +``` + +### Source Code (repository root) + +```text +apps/ +├── Frontend/ # Unchanged except non-regression gates +├── Administration-FE/ +│ ├── src/ +│ │ ├── components/ # Sign-in + workspace in one island; permission-aware controls +│ │ ├── lib/ +│ │ │ ├── token-store.ts # In-memory access + refresh (new) +│ │ │ └── admin-api.ts # Bearer client; refresh-once on 401 +│ │ ├── generated/ # Types from specs/002-auth-rbac/contracts/ +│ │ └── i18n/en.json # Denial / sign-in keys +│ └── tests/ +│ ├── e2e/ # Sign-in, grant, deny, no sign-up +│ └── unit/ +└── Backend/ + ├── src/flycatch_api/ + │ ├── api/ + │ │ ├── admin_auth.py # sign-in, refresh, sign-out, session + │ │ ├── admin_management.py # Bearer + records.view / drafts.save + │ │ └── publish.py # Bearer + records.publish + │ ├── models/ # Role, RolePermission, AdministratorRole; session = refresh + │ ├── schemas/ # Pydantic aligned to 002 contracts + │ ├── services/ + │ │ ├── auth_service.py # Password + JWT + refresh rotation + │ │ └── rbac_service.py # Union permissions; request-time check + │ ├── security/ + │ │ ├── jwt.py # Issue / verify access JWT + │ │ └── dependencies.py # Bearer principal + require_permission + │ └── cli/ + │ ├── bootstrap.py # flycatch-bootstrap (new) + │ └── provision_admin.py # Required --role + ├── alembic/versions/ # 002 roles + refresh-session columns + └── tests/ + ├── contract/ + ├── integration/ + └── unit/ + +deployment/ # jwt_secret + access TTL in .env.example only +``` + +**Structure Decision**: Keep the foundation’s three applications. This feature changes `apps/Backend` and `apps/Administration-FE` only. No fourth app. Contracts for this feature live under `specs/002-auth-rbac/contracts/`. + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +No violations. Table left empty. diff --git a/specs/002-auth-rbac/quickstart.md b/specs/002-auth-rbac/quickstart.md new file mode 100644 index 0000000..8ded350 --- /dev/null +++ b/specs/002-auth-rbac/quickstart.md @@ -0,0 +1,103 @@ +# Quickstart: Authentication and Authorisation (RBAC) + +**Feature**: `002-auth-rbac` +**Date**: 2026-08-14 +**Purpose**: Runnable validation that staff JWT auth and RBAC work end-to-end. Implementation details belong in `tasks.md`. + +Related artifacts: [spec.md](./spec.md), [data-model.md](./data-model.md), [contracts/](./contracts/), [plan.md](./plan.md), [research.md](./research.md). + +## Prerequisites + +- Foundation stack running (see [001-website-foundation/quickstart.md](../001-website-foundation/quickstart.md)) +- Node.js 22 LTS, Python 3.12, Docker Compose +- Operator-chosen emails and passwords for **two** staff users (minimum 12 characters). Do not use committed defaults. + +## Setup + +1. Copy or update `deployment/.env` with `jwt_secret` (long random) in addition to existing Backend secrets. +2. Start services: `docker compose -f deployment/docker-compose.yml up -d`. +3. Apply Backend migrations (includes roles and refresh-session columns). +4. Run bootstrap (see [bootstrap.cli.yaml](./contracts/bootstrap.cli.yaml)): + + ```text + flycatch-bootstrap \ + --user-1-email admin1@example.com \ + --user-2-email admin2@example.com \ + --user-2-role editor + ``` + + Supply passwords via prompt or flags. Expect two users and roles `administrator` / `editor`. Re-run with the same emails and expect no duplicates. + +5. Generate Administration FE types/client from `specs/002-auth-rbac/contracts/` (`admin-auth.v2`, `admin-rbac.v1`, `admin-management.v2`, `publish.v2`). Confirm Backend served OpenAPI matches those files. +6. Open the gateway origin `/admin`. Expect sign-in only — no register or create-account control. + +Do not store issued tokens in `localStorage`, `sessionStorage`, or cookies. The Administration FE MUST keep them in memory and send `Authorization: Bearer `. + +## Validation scenarios + +### V1 — Bootstrap defaults (US1, SC-001, SC-006) + +1. On an empty staff table, run bootstrap with two identities and secrets. +2. **Expect**: Two active users; role `administrator` has view + draft + publish; role `editor` has view + draft only. +3. Run bootstrap again with the same emails. +4. **Expect**: Zero duplicate users or roles; command reports defaults already exist. +5. Inspect logs and public HTML. +6. **Expect**: No passwords, refresh tokens, or JWT secrets. + +### V2 — Password sign-in and sign-out (US2, SC-002, SC-003, SC-004) + +1. Sign in as user 1 with the correct password ([admin-auth.v2.yaml](./contracts/admin-auth.v2.yaml)). +2. **Expect**: `200` with `access_token`, `refresh_token`, `token_type: bearer`, and `session.roles` / `session.permissions`. Workspace renders without a full page reload. +3. Confirm subsequent admin requests include `Authorization: Bearer` and do not send an `admin_session` cookie. +4. Sign in with a wrong password, then with an unknown email, then as an inactive user (if you deactivate a fixture). +5. **Expect**: Same generic error and message key; zero refresh sessions created. +6. Confirm the sign-in screen has no sign-up action. +7. Sign out. +8. **Expect**: Refresh session revoked; further admin requests are 401; workspace is not usable until sign-in. + +### V3 — Refresh token is required (FR-004, plan input) + +1. Sign in; wait for or force access-token expiry (15 minutes, or a test clock). +2. Perform a protected GET. The client MUST call `POST /admin/auth/refresh` with the in-memory refresh token and retry. +3. **Expect**: New token pair; original refresh hash revoked; work continues. +4. Repeat refresh after idle (30 minutes) or absolute (12 hours) expiry. +5. **Expect**: 401; treated as signed out. Unsaved edits do not publish. + +### V4 — Authorised actions (US3, SC-005) + +1. Sign in as the Administrator user. +2. View site settings and the `home` page; save a draft; publish ([admin-management.v2.yaml](./contracts/admin-management.v2.yaml), [publish.v2.yaml](./contracts/publish.v2.yaml)). +3. **Expect**: Each step succeeds. Public HTML still shows the previous published snapshot until the documented rebuild. + +### V5 — Denied publish (US4, SC-005, SC-007) + +1. Sign in as the Editor user (or user 2 if bootstrapped with `--user-2-role editor`). +2. **Expect**: Draft save works. Publish control is absent or clearly disabled; denial copy uses `admin.action.forbidden`. +3. Send `POST /api/v1/admin/publish` directly with the Editor access token. +4. **Expect**: `403` `permission_denied` for `records.publish`; published site unchanged; `GET /admin/auth/session` still succeeds (still signed in). +5. Request the same publish URL with no `Authorization` header. +6. **Expect**: `401` (not 403); no staff content, roles, or permissions in the body. + +### V6 — Contracts, i18n, accessibility, public non-regression (SC-008, SC-009) + +1. Validate every file in [contracts/](./contracts/) with openapi-spec-validator (skip `bootstrap.cli.yaml` as CLI, not OpenAPI). +2. Compare Backend served OpenAPI and Administration FE generated types to the same YAML. +3. **Expect**: No drift; no hand-written token/permission DTOs. +4. Scan sign-in and denial UI for hard-coded user-facing strings. +5. Run axe on sign-in and a permission-denied state (WCAG 2.2 AA, zero critical). +6. Rebuild `apps/Frontend` and confirm sitemap/robots still exclude `/admin` and `/api`; public JS budget remains 0. + +## Quality gates (must pass before complete) + +- Bootstrap idempotency and fail-closed missing inputs +- Sign-in success returns both tokens; generic failure creates 0 sessions +- Refresh rotation and idle/absolute expiry +- Bearer required on protected routes +- Permission grant (Administrator publish) and denial (Editor direct publish) +- Contract parity for 002 OpenAPI files +- Message keys + axe AA on sign-in and denial +- Public non-regression (no admin leakage, no public JS) + +## Out of this guide + +Implementation of routers, migrations, token-store code, and full test suites belongs in `tasks.md` and the implementation phase. diff --git a/specs/002-auth-rbac/research.md b/specs/002-auth-rbac/research.md new file mode 100644 index 0000000..f0871bc --- /dev/null +++ b/specs/002-auth-rbac/research.md @@ -0,0 +1,186 @@ +# Research: Authentication and Authorisation (RBAC) + +**Feature**: `002-auth-rbac` +**Date**: 2026-08-14 +**Status**: Complete — all Technical Context items resolved + +This feature extends `001-website-foundation`. Stack, surfaces, and public static delivery stay the same. The plan input requires **JWT access tokens plus required refresh tokens**, both held in **Administration FE memory**, with the access token sent as `Authorization: Bearer`. That choice supersedes the foundation cookie session for staff authentication. + +--- + +## 1. Authentication mechanism (JWT + refresh) + +**Decision**: Password sign-in issues **both** an access JWT and an opaque refresh token. Refresh tokens are mandatory (not optional, not a later add-on). The access token is a signed JWT (HS256 via PyJWT). The refresh token is a high-entropy opaque secret stored only as a hash on a server-backed session row. Idle timeout remains 30 minutes and absolute lifetime remains 12 hours, both enforced on the refresh session (same values as the foundation). Access JWT lifetime is **15 minutes**. + +**Rationale**: The plan input requires JWT + refresh. The specification still requires a server-backed session with idle and absolute timeouts (FR-004), generic credential failures (FR-006), and request-time authorisation (FR-015). A short-lived JWT carries identity only; a hashed refresh record is the revocable session. Permissions MUST NOT be trusted from JWT claims. + +**Access JWT claims** (identity only): + +| Claim | Meaning | +| --- | --- | +| `sub` | Administrator UUID | +| `sid` | Refresh-session UUID (so sign-out can revoke without the refresh body) | +| `typ` | Constant `access` | +| `iat`, `exp`, `jti` | Issued-at, expiry, unique id | + +**Alternatives considered**: + +- **Keep foundation HttpOnly cookie session**: Simpler and already implemented, but rejected by the plan input. +- **JWT only, no refresh**: Cannot meet 30-minute idle / 12-hour absolute session rules without a long-lived access token, which cannot be revoked promptly. +- **Refresh JWT (signed, not stored)**: Harder to revoke and rotate; opaque hashed refresh is the server-backed session FR-004 requires. +- **Put roles/permissions in the access JWT**: Violates FR-015 (stale grants after role change). + +--- + +## 2. Token storage and transport + +**Decision**: Administration FE stores `access_token` and `refresh_token` in **process memory only** (module-level store or React context). Neither token is written to `localStorage`, `sessionStorage`, cookies, or HTML. Every protected Administration API request sends `Authorization: Bearer `. The client MUST NOT use `credentials: 'include'` for session cookies. After a successful sign-in, the workspace MUST switch view **without** a full document navigation (`window.location`); a reload clears memory and is treated as signed out. + +**Rationale**: Plan input. FR-007 forbids durable client storage that is not required. Memory is required for Bearer auth and is lost on reload, which is accepted. Full-page redirects after sign-in (current `SignInForm` / `AdminShell` behaviour) would drop the tokens and MUST be removed. + +**Refresh behaviour**: On `401` from an expired access token, the client calls `POST /admin/auth/refresh` once with the in-memory refresh token, replaces both tokens, and retries the original request. Failure or a missing refresh token is treated as signed out. + +**Alternatives considered**: + +- **`localStorage` / `sessionStorage`**: Survives reload but is readable by XSS and is durable client storage FR-007 rejects. +- **HttpOnly cookie for refresh + memory for access**: Hybrid; still uses cookies the plan input forbids for these tokens. +- **Keep `window.location` after sign-in**: Wipes memory; incompatible with the storage rule. + +--- + +## 3. CSRF and the foundation cookie/CSRF pair + +**Decision**: Remove the Administration CSRF synchronizer (`X-CSRF-Token` and `GET /admin/csrf`) for this feature. Bearer tokens in an `Authorization` header are not sent automatically by the browser on cross-site form posts, which satisfies FR-004 (“credentials MUST NOT be usable as a cross-site request”). Foundation `admin-auth.v1`, cookie `admin_session`, and CSRF on management/publish mutations are **superseded** by `admin-auth.v2` plus Bearer on management/publish v2 contracts. + +**Rationale**: CSRF tokens exist to protect cookie-authenticated mutations. They add weight and a confused 403 (`csrf_failed` vs permission denial). Bearer-in-header is the CSRF mitigation. + +**Alternatives considered**: + +- **Keep CSRF alongside Bearer**: Extra round-trip and a second failure mode; not required once cookies are gone. +- **SameSite cookie + CSRF**: Foundation approach; rejected by the JWT/Bearer input. + +--- + +## 4. Authorisation model (RBAC) + +**Decision**: Named permissions are a **fixed catalogue** stored as an enum on `role_permissions`, not a freely editable permission table. Roles are rows. A user may have many roles; effective permissions are the **union**. Default roles: + +| Role | Permissions | +| --- | --- | +| `administrator` | `records.view`, `drafts.save`, `records.publish` | +| `editor` | `records.view`, `drafts.save` | + +Enforcement is server-side on every protected action. The workspace hides or disables controls using the session payload; hiding is not sufficient (FR-012). + +**Request-time evaluation**: After JWT validation, the backend loads the administrator, rejects inactive users as **unauthenticated** (401), loads current role assignments, and checks the required permission. Missing permission → **403** `permission_denied` while the session remains valid (FR-016). Unauthenticated → **401**, never 403 (FR-013). + +**Operation map**: + +| Action | Permission | +| --- | --- | +| GET managed record (site settings, page) | `records.view` | +| PATCH draft | `drafts.save` | +| POST publish | `records.publish` | +| Sign-in / refresh / sign-out / session | Authenticated session only (no RBAC permission) | + +**Alternatives considered**: + +- **Permission rows as a product table**: Over-modelled for three fixed actions. +- **One role per user**: Rejected by FR-009 (union of multiple roles). +- **Evaluate permissions only at sign-in**: Violates FR-015. + +--- + +## 5. Bootstrap and provisioning + +**Decision**: Operator CLI `flycatch-bootstrap` (new Backend entry point) creates the two default roles and at least two users in one transaction-like fail-closed run. Identities and secrets come from flags or prompts — never from committed defaults. Idempotent on role `name` and user `email`. At least one user receives `administrator`; the second defaults to `administrator` unless `--user-2-role editor` is set. Existing `flycatch-provision-admin` gains a required `--role` from the catalogue. + +**Rationale**: FR-017–FR-024. No Administration UI for users or roles. Bootstrap is not an HTTP API. + +**Alternatives considered**: + +- **HTTP bootstrap endpoint**: Would be a public or semi-public account-creation path; out of scope. +- **Seed passwords in `.env.example`**: Forbidden by FR-020. +- **Only one default user**: Violates the two-user requirement. + +--- + +## 6. Contract versioning + +**Decision**: Publish new OpenAPI 3.1 files in `specs/002-auth-rbac/contracts/`. `admin-auth.v2.yaml` replaces `001` `admin-auth.v1.yaml` (breaking: tokens in JSON, Bearer, refresh). `admin-management.v2.yaml` and `publish.v2.yaml` replace cookie + CSRF with Bearer and distinguish 401 vs 403 `permission_denied`. `admin-rbac.v1.yaml` is the permission/role/denial catalogue. Payload schemas for pages and site settings stay referenced from `001` contracts. CLI bootstrap is documented as `bootstrap.cli.yaml`. + +**Rationale**: Constitution III and foundation rule that breaking changes get a new version file. Administration FE MUST generate types from the v2/v1 files in this feature directory. + +**Alternatives considered**: + +- **Patch 001 v1 files in place**: Silent breaking change; rejected. +- **Single mega-spec**: Harder to review; inconsistent with foundation file-per-boundary. + +--- + +## 7. Libraries and secrets + +**Decision**: Add **PyJWT** (`PyJWT[crypto]` not required for HS256) on the Backend. Reuse Argon2 for passwords. Hash refresh tokens with SHA-256 plus `session_secret` (same pattern as foundation session tokens). Add `jwt_secret` (HS256 signing key) and `jwt_access_minutes` (default 15). Do not add Redis or a token denylist; revocation is the refresh-session row. + +**Rationale**: PyJWT is the maintained Python JWT library. A denylist for every access JWT would add infrastructure the scale does not need; 15-minute expiry plus inactive-user checks on each request is enough. + +**Alternatives considered**: + +- **python-jose**: Less actively maintained. +- **Redis session store**: Extra infrastructure; PostgreSQL already holds sessions. +- **RS256 key pair**: Unnecessary for a single Backend signing its own tokens. + +--- + +## 8. Administration FE session UX + +**Decision**: One hydrated React island owns auth state. Sign-in success writes both tokens to memory and renders the workspace in the same document. Sign-out calls the revoke endpoint, clears memory, and shows sign-in in the same document. Publish/draft controls are omitted or `disabled` + `aria-disabled` when the session payload lacks the permission; denial copy uses `admin.action.forbidden` (already in the catalogue). + +**Rationale**: Memory storage cannot survive `window.location` hops. Native form controls stay (constitution II, IX). No new UI kit. + +**Alternatives considered**: + +- **Separate Astro pages for sign-in vs workspace with full navigation**: Incompatible with in-memory tokens unless storage is persisted. + +--- + +## 9. Testing and quality gates + +**Decision**: Extend existing pytest / Playwright / axe / OpenAPI gates. New gates: + +| Gate | Must prove | +| --- | --- | +| Bootstrap | Two users, two roles, idempotent re-run, fail-closed on missing secrets | +| Sign-in | 200 + both tokens; wrong password / unknown email / inactive → same generic 401, zero sessions | +| Refresh | Required; rotates refresh hash; idle/absolute expiry → 401 | +| Bearer | Protected routes reject missing/invalid `Authorization` as 401 | +| RBAC grant | Administrator can view, draft, publish | +| RBAC deny | Editor draft succeeds; publish (UI and direct POST) is 403; user stays signed in | +| Contracts | Served OpenAPI and Administration FE types match `002` contracts | +| i18n / a11y | Message keys only; WCAG 2.2 AA on sign-in and denial | +| Public non-regression | No admin JS or tokens on public pages; sitemap still excludes `/admin` | + +**Rationale**: FR-030, SC-001–SC-009, constitution XIII. + +--- + +## 10. Public site and performance + +**Decision**: No changes to `apps/Frontend` templates, budgets, or Core Web Vitals targets. This feature MUST NOT add script or layout weight to public pages. Administration UI remains exempt from ranking vitals (foundation FR-044) and MUST stay usable on supported viewports. + +**Rationale**: Spec non-regression for constitution I, VI, VII. + +--- + +## Clarifications resolved + +| Item | Resolution | +| --- | --- | +| Auth product | JWT access + required opaque refresh; not cookie session | +| Token storage | Frontend memory only; Bearer header | +| Session timeouts | Idle 30 min and absolute 12 h on refresh session; access JWT 15 min | +| Permission source of truth | Database role assignments at request time | +| CSRF | Removed for admin mutations; Bearer is the mitigation | +| Bootstrap | Operator CLI, not an HTTP API | +| Sign-up | Still absent | +| Extra stores | None (no Redis) | diff --git a/specs/002-auth-rbac/spec.md b/specs/002-auth-rbac/spec.md new file mode 100644 index 0000000..5a572b8 --- /dev/null +++ b/specs/002-auth-rbac/spec.md @@ -0,0 +1,217 @@ +# Feature Specification: Authentication and Authorisation (RBAC) + +**Feature Branch**: `002-auth-rbac` + +**Created**: 2026-08-14 + +**Status**: Draft + +**Input**: User description: "Define Authentication & Authorization: Password-based authentication; user sign-up is not required. RBAC using roles and permissions. Bootstrap mechanism for creating at least two default administrative users and their roles. Keep the design minimal and aligned with the existing architecture." + +**Constitution alignment**: This specification implements mandatory governance from `.specify/memory/constitution.md` (v1.0.0). Requirements trace primarily to principles VIII (security), III (contract-first), V (i18n), IX (accessibility), II (native elements), VI (performance), XI (responsive UI), XII (production-grade), and XIII (quality gates). Public SEO/AEO (I) and Core Web Vitals (VII) apply only as a non-regression: this feature MUST NOT weaken public delivery or expose the Administration UI to search. + +## Scope + +This feature **defines** staff authentication and authorisation for the existing Administration UI. It extends the foundation’s provisioned-administrator model; it does not add public visitor accounts or a new product surface. + +### In scope + +- Password-based sign-in and sign-out for provisioned staff (no self-service sign-up) +- Role-based access control: roles grant named permissions; a staff member’s allowed actions are the union of permissions from their assigned roles +- Server-side enforcement of every protected administration action (the workspace MAY hide unauthorised controls; hiding is not sufficient) +- A one-time, operator-run bootstrap that creates the default roles and at least two default administrative users, then assigns those users their roles +- Session context that tells the signed-in workspace which actions the person may perform +- Accessible, internationalised sign-in and permission-denied messages consistent with the Administration UI baselines + +### Out of scope + +- Public visitor accounts, customer login, or any authenticated public area +- Self-service registration, invitation links, or “create account” on the sign-in screen +- Single sign-on, social login, or third-party identity providers +- Self-service password recovery, reset, or email verification +- An Administration UI for creating users, editing roles, or assigning permissions (operators use bootstrap and the existing operator provisioning path) +- Per-record or per-field access lists, approval workflows, or legal-review products +- Changing public static delivery, publish mechanics, or content models beyond checking the caller’s permission + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Operator bootstraps default users and roles (Priority: P1) + +An authorised operator prepares a new environment so staff can sign in. They run the documented bootstrap once. The system creates the default roles, creates at least two administrative users, assigns each user their role(s), and confirms success without exposing secrets in logs or public pages. After bootstrap, those users can sign in; nobody else can register themselves. + +**Why this priority**: Without bootstrap, the Administration UI has no first users and no roles. Everything else depends on this. + +**Independent Test**: Run bootstrap on an empty environment, confirm two users and the default roles exist with assignments, then sign in as each bootstrapped user. Do not open a sign-up screen. + +**Acceptance Scenarios**: + +1. **Given** an environment with no staff users, **When** an operator completes bootstrap with the documented inputs (identities and secrets for at least two users), **Then** at least two active administrative users exist, each assigned at least one default role +2. **Given** bootstrap has completed, **When** the default role catalogue is inspected, **Then** it includes an Administrator role with every administration permission and an Editor role with view and draft permissions only (no publish) +3. **Given** bootstrap has completed, **When** a person visits the Administration UI, **Then** they see sign-in only — there is no sign-up, register, or create-account action +4. **Given** bootstrap has already created the default users and roles, **When** the operator runs bootstrap again with the same identities, **Then** the system does not create duplicate users or roles and reports that the defaults already exist +5. **Given** bootstrap credentials, **When** they are stored or displayed, **Then** secrets are never written to public pages, client-delivered assets, or ordinary application logs + +--- + +### User Story 2 - Provisioned staff member signs in with a password (Priority: P1) + +A bootstrapped or later-provisioned staff member opens the Administration UI, enters their email and password, and reaches the workspace. Failed attempts do not reveal whether the email exists. There is no path to create an account from this screen. + +**Why this priority**: Password sign-in is the only way staff reach administration. If this fails, roles never matter. + +**Independent Test**: Sign in with a valid bootstrapped account; retry with a wrong password and with an unknown email; confirm both failures look the same and no session is created. + +**Acceptance Scenarios**: + +1. **Given** an active provisioned user and a correct password, **When** they submit sign-in, **Then** they reach the Administration UI and the workspace knows which roles and permissions they have +2. **Given** a wrong password or an unknown email, **When** they submit sign-in, **Then** access is denied with the same generic message, no session is created, and the response does not say whether the account exists +3. **Given** an inactive provisioned user and the correct password, **When** they submit sign-in, **Then** access is denied with the same generic message as an unknown account +4. **Given** the sign-in screen, **When** a visitor looks for a way to register, **Then** no sign-up control or self-service account-creation path is available +5. **Given** a signed-in user, **When** they sign out, **Then** further administration requests do not show staff-only content until they sign in again + +--- + +### User Story 3 - Authorised staff member performs an allowed action (Priority: P2) + +A signed-in staff member whose roles include the required permission completes an existing administration action (view a record, save a draft, or publish). The backend accepts the action. The workspace shows the controls they are allowed to use. + +**Why this priority**: Authentication without usable authorisation does not change staff work. This story proves permissions grant access to the existing draft/publish path. + +**Independent Test**: Sign in as a user whose roles include draft and publish; view a placeholder record, save a draft, and publish; confirm each step succeeds. + +**Acceptance Scenarios**: + +1. **Given** a signed-in user whose effective permissions include viewing records, **When** they open a managed record they are allowed to see, **Then** the workspace shows the record and does not treat them as unauthorised +2. **Given** a signed-in user whose effective permissions include saving drafts, **When** they save a draft, **Then** the draft is stored and the public site is unchanged +3. **Given** a signed-in user whose effective permissions include publish, **When** they publish, **Then** the documented publish path completes as in the foundation +4. **Given** a user assigned more than one role, **When** their access is evaluated, **Then** they receive every permission from any of those roles (union, not intersection) + +--- + +### User Story 4 - Staff member is denied an action they are not permitted to perform (Priority: P2) + +A signed-in Editor (or any user whose roles lack publish) tries to publish. The workspace does not offer a working publish control, and a direct request to publish is refused. The user remains signed in and can still perform actions they are allowed. + +**Why this priority**: RBAC only has value if missing permissions are enforced on the server, not only hidden in the interface. + +**Independent Test**: Sign in as a user who has view and draft but not publish; confirm publish is refused in the workspace and by a direct request; confirm draft save still works. + +**Acceptance Scenarios**: + +1. **Given** a signed-in user whose roles do not include publish, **When** they view a record they may draft, **Then** they can save a draft and they cannot complete publish +2. **Given** that same user, **When** a publish request is sent anyway, **Then** the system refuses it, does not change the published site, and leaves them signed in +3. **Given** a signed-in user with no permission for a given action, **When** the workspace renders that action, **Then** the control is absent or clearly disabled and the denial message is accessible and uses a message key +4. **Given** an unauthenticated person, **When** they request a protected administration action, **Then** they are treated as signed out (not as “permission denied”) and see no staff-only content + +--- + +### Edge Cases + +- Bootstrap is run with missing required user identities or secrets — it MUST fail without creating a partial, unusable default set (no users without roles, no roles without the documented defaults) +- Bootstrap is run when one of the two default users already exists and the other does not — it MUST create only the missing user and MUST NOT duplicate the existing one +- A user has no roles, or only roles with no permissions — they MAY sign in if active, but every protected mutation MUST be denied +- A user’s roles are changed after they signed in — subsequent protected actions MUST use current assignments, not a stale grant from sign-in time +- Session idle or absolute timeout — the person MUST be treated as signed out; unsaved edits MUST NOT publish themselves +- Concurrent sign-in from another browser — existing session rules from the foundation still apply; authorisation is evaluated per request +- Inactive user with a still-valid-looking session — they MUST be treated as signed out +- Failed sign-in MUST NOT disclose whether the email exists, whether the user is inactive, or what roles they would have +- Public site requests MUST remain unauthenticated and MUST NOT depend on staff roles +- Direct requests to Administration UI addresses by crawlers MUST remain non-indexable and MUST NOT leak staff identities, roles, or permissions in public HTML + +## Requirements *(mandatory)* + +### Functional Requirements + +#### Password authentication (no sign-up) + +- **FR-001**: Staff MUST sign in to the Administration UI with an email and a password. No other sign-in method is in this feature. +- **FR-002**: The system MUST NOT offer or accept self-service sign-up, registration, or account creation from the Administration UI or any public page. +- **FR-003**: Only provisioned, active users MUST be able to sign in. Inactive or unknown credentials MUST be rejected with the same generic error. +- **FR-004**: Sign-in MUST create a server-backed session with idle timeout and absolute lifetime consistent with the foundation. Credentials MUST NOT be usable as a cross-site request to change administration data. +- **FR-005**: Sign-out MUST end the session. After sign-out, administration content MUST NOT remain usable from the same browser without signing in again. +- **FR-006**: Failed sign-in MUST NOT disclose whether an account exists, whether it is inactive, or which roles it has. +- **FR-007**: Credentials and session secrets MUST NOT appear in public HTML, ordinary logs, or client storage that is not required for the session to function. +- **FR-008**: Sign-in and sign-out MUST remain on the existing Administration UI surface and MUST reuse the existing staff session boundary (extended only as needed to carry authorisation context). + +#### Roles and permissions + +- **FR-009**: Access to protected administration actions MUST be determined by RBAC: a **Role** is a named set of **Permissions**; a user is assigned one or more roles; effective permissions are the union of those roles’ permissions. +- **FR-010**: The default role catalogue MUST include at least: + - **Administrator** — every permission in this feature’s permission catalogue + - **Editor** — view and draft permissions only; MUST NOT include publish +- **FR-011**: The permission catalogue MUST stay minimal and MUST map to existing administration actions only: + - view managed records + - save drafts + - publish +- **FR-012**: The backend MUST refuse any protected action when the signed-in user lacks the matching permission. A hidden or disabled control in the workspace is not sufficient. +- **FR-013**: Unauthenticated requests to protected actions MUST be rejected as unauthenticated, not as insufficient permission. +- **FR-014**: After a successful sign-in (and on session check), the workspace MUST receive the user’s identity plus their role names and effective permissions so it can show only allowed controls. +- **FR-015**: Authorisation for a protected action MUST be evaluated from current role assignments at request time, not from a grant that cannot be revoked until the session ends. +- **FR-016**: A signed-in user who lacks permission MUST remain signed in; the refusal MUST use an accessible, internationalised message and MUST NOT perform the action. + +#### Bootstrap of default users and roles + +- **FR-017**: An operator-run bootstrap MUST create the default roles (Administrator and Editor) and at least two default administrative users, and MUST assign each of those users at least one role. +- **FR-018**: At least one of the two default users MUST be assigned the Administrator role so the environment is not left without a fully authorised operator. +- **FR-019**: The second default user MUST also be assigned a default role from the catalogue (Administrator or Editor) as specified by the operator input; if the operator does not choose, the second user MUST receive the Administrator role. +- **FR-020**: Bootstrap MUST require the operator to supply each default user’s identity and secret. Defaults MUST NOT ship with a well-known password in the product. +- **FR-021**: Bootstrap MUST be idempotent for the default role names and the supplied user identities: a repeat run MUST NOT create duplicates and MUST leave existing matching users and roles intact. +- **FR-022**: Bootstrap MUST fail closed if required inputs are missing or invalid, without leaving users who cannot sign in or roles that do not match FR-010. +- **FR-023**: Additional staff MAY be added later only through the existing operator provisioning path (not through sign-up). Newly provisioned users MUST be assigned at least one role from the catalogue. +- **FR-024**: Bootstrap and provisioning are operator actions. They MUST NOT be available as self-service Administration UI screens in this feature. + +#### Non-functional (constitution) + +- **FR-025**: User-facing strings for sign-in, sign-out, validation, and permission denial MUST be externalised as message keys (constitution V). +- **FR-026**: Sign-in and denial states MUST meet WCAG 2.2 Level AA: labels, field-level errors that preserve valid input, keyboard operation, and visible focus (constitution IX). +- **FR-027**: Administration responses MUST remain non-indexable and MUST NOT appear in the public sitemap. This feature MUST NOT add Administration UI links to anonymous public pages (constitution I, VIII). +- **FR-028**: Versioned, machine-readable contracts for sign-in, session, and permission-denied outcomes MUST be published or updated before consumers change (constitution III). The existing administration sign-in contract is the starting point; it MAY be extended to include roles and permissions. +- **FR-029**: This feature MUST NOT add script or layout weight to public pages. Administration UI changes MUST reuse existing layout regions and design patterns (constitution II, VI, X, XI). +- **FR-030**: Quality gates MUST cover bootstrap idempotency, password sign-in success and generic failure, permission grant, permission denial (including a direct request), and contract validation (constitution XIII). + +### Key Entities + +- **Administrator (staff user)**: A provisioned staff identity (email, active flag, credential). Not a public visitor. Created by bootstrap or the operator provisioning path. Assigned one or more Roles. +- **Role**: A named set of Permissions (at minimum Administrator and Editor). Assigned to users; does not itself sign in. +- **Permission**: A named capability that corresponds to one existing administration action: view records, save drafts, or publish. +- **Role assignment**: The link between a staff user and a Role. A user’s effective permissions are the union of assigned roles. +- **Admin Session**: The signed-in period for one staff user. Required for administration. Carries enough authorisation context for the workspace to render allowed actions; enforcement still occurs on each protected request. +- **Bootstrap set**: The operator-supplied default users (at least two) plus the default roles created in an environment. Not a public record. Repeatable without duplication. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: After one successful bootstrap, 100% of trial environments have at least two active users who can sign in, and both default roles (Administrator and Editor) exist with the documented permissions. +- **SC-002**: In a measured trial, a provisioned user completes sign-in with a correct password in under 30 seconds on a typical office connection, on 100% of first valid attempts. +- **SC-003**: 100% of failed sign-in attempts using a wrong password, an unknown email, or an inactive account produce the same generic outcome and create 0 sessions. +- **SC-004**: 0 sign-up, register, or self-service account-creation paths are present on the Administration UI or the public site (manual or automated check). +- **SC-005**: In 100% of trials, a user with publish permission can publish a placeholder record, and a user without publish permission cannot — including when they send the publish request directly. The public site changes only in the allowed case. +- **SC-006**: Repeating bootstrap with the same default identities creates 0 duplicate users and 0 duplicate default roles in 100% of trials. +- **SC-007**: 100% of unauthenticated requests to protected administration actions fail to expose staff-only content, roles, or permissions. +- **SC-008**: 100% of sign-in and permission-denied messages are referenced by message key (zero hard-coded user-facing strings in those flows). +- **SC-009**: 100% of sign-in and denial screens pass WCAG 2.2 AA automated checks with zero critical violations. + +## Assumptions + +- This feature extends `001-website-foundation`; it does not replace the Administration UI, the public static site, or the draft/publish path. +- Authentication remains staff-only. There are still no authenticated public visitor areas. +- Password sign-in continues the foundation’s confidential, server-backed session (idle and absolute timeouts, generic failure, no disclosure of account existence). +- The two default users are both staff accounts for the Administration UI. At least one is a full Administrator. If the operator does not specify otherwise, both receive the Administrator role so a single person is not the only fully authorised operator. +- The Editor role exists so RBAC is testable and so a later-provisioned (or operator-assigned) user can draft without publishing. This feature does not require a user-management screen to assign Editor. +- Additional users after bootstrap use the existing operator provisioning path, now including a role assignment. No Administration UI for user or role management in this phase. +- Password recovery, password change by the signed-in user, and SSO remain later work unless a later specification adds them. +- A user may hold more than one role; permissions combine as a union. A user with no permissions can be signed in but cannot complete protected mutations. +- Permission names stay aligned with today’s three administration actions. New content types later SHOULD reuse view / draft / publish rather than inventing a parallel catalogue. +- Bootstrap secrets are supplied by the operator per environment (local, preview, production). They are not committed as known defaults. +- Public SEO, Core Web Vitals, and static delivery baselines from the foundation remain unchanged; this feature only ensures administration stays private and non-indexable. +- WCAG 2.2 Level AA remains the accessibility target for sign-in and denial states. + +## Constraints + +- Specifications and implementations MUST remain aligned with the existing three-surface architecture (public frontend, backend, Administration UI). A fourth identity product is out of scope. +- Stack and library choices belong to `/speckit-plan`. This specification MUST stay technology-agnostic while naming the existing contracts and operator path as the extension points. +- Public pages MUST stay statically deliverable and MUST NOT require a staff session. +- Self-registration MUST NOT be added “for convenience” in any environment, including local. +- Least privilege: default Editor MUST NOT receive publish. Default passwords MUST NOT be embedded in the product. +- Weakening foundation security, accessibility, i18n, or administration-isolation baselines MUST require a documented, approved exception. From b6b2e76284af0bc50a678f7ba9b90115b2277acd Mon Sep 17 00:00:00 2001 From: athulrajtflycatchtech Date: Mon, 17 Aug 2026 10:40:22 +0530 Subject: [PATCH 2/3] feat(auth-rbac): implement RBAC data models, JWT authentication, and bootstrap service for user roles and permissions management --- apps/Administration-FE/scripts/check-i18n.mjs | 32 ++ apps/Administration-FE/src/lib/token-store.ts | 26 ++ .../tests/e2e/admin-auth.spec.ts | 32 ++ .../tests/e2e/admin-rbac-deny.spec.ts | 20 ++ .../versions/002_rbac_refresh_session.py | 159 ++++++++++ .../Backend/src/flycatch_api/cli/bootstrap.py | 71 +++++ .../flycatch_api/models/administrator_role.py | 31 ++ apps/Backend/src/flycatch_api/models/role.py | 26 ++ .../flycatch_api/models/role_permission.py | 38 +++ .../src/flycatch_api/schemas/admin_auth.py | 62 ++++ .../src/flycatch_api/schemas/admin_rbac.py | 13 + apps/Backend/src/flycatch_api/security/jwt.py | 63 ++++ .../services/bootstrap_service.py | 162 ++++++++++ .../src/flycatch_api/services/rbac_service.py | 38 +++ .../tests/integration/test_admin_auth.py | 124 ++++++++ .../tests/integration/test_bootstrap.py | 110 +++++++ .../tests/integration/test_rbac_deny.py | 55 ++++ .../tests/integration/test_rbac_grant.py | 63 ++++ apps/Frontend/.prettierignore | 9 + specs/002-auth-rbac/tasks.md | 290 ++++++++++++++++++ 20 files changed, 1424 insertions(+) create mode 100644 apps/Administration-FE/scripts/check-i18n.mjs create mode 100644 apps/Administration-FE/src/lib/token-store.ts create mode 100644 apps/Administration-FE/tests/e2e/admin-auth.spec.ts create mode 100644 apps/Administration-FE/tests/e2e/admin-rbac-deny.spec.ts create mode 100644 apps/Backend/alembic/versions/002_rbac_refresh_session.py create mode 100644 apps/Backend/src/flycatch_api/cli/bootstrap.py create mode 100644 apps/Backend/src/flycatch_api/models/administrator_role.py create mode 100644 apps/Backend/src/flycatch_api/models/role.py create mode 100644 apps/Backend/src/flycatch_api/models/role_permission.py create mode 100644 apps/Backend/src/flycatch_api/schemas/admin_auth.py create mode 100644 apps/Backend/src/flycatch_api/schemas/admin_rbac.py create mode 100644 apps/Backend/src/flycatch_api/security/jwt.py create mode 100644 apps/Backend/src/flycatch_api/services/bootstrap_service.py create mode 100644 apps/Backend/src/flycatch_api/services/rbac_service.py create mode 100644 apps/Backend/tests/integration/test_admin_auth.py create mode 100644 apps/Backend/tests/integration/test_bootstrap.py create mode 100644 apps/Backend/tests/integration/test_rbac_deny.py create mode 100644 apps/Backend/tests/integration/test_rbac_grant.py create mode 100644 apps/Frontend/.prettierignore create mode 100644 specs/002-auth-rbac/tasks.md diff --git a/apps/Administration-FE/scripts/check-i18n.mjs b/apps/Administration-FE/scripts/check-i18n.mjs new file mode 100644 index 0000000..055ca56 --- /dev/null +++ b/apps/Administration-FE/scripts/check-i18n.mjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const srcDir = join(dirname(fileURLToPath(import.meta.url)), '../src'); +const files = [ + 'components/SignInForm.tsx', + 'components/AdminShell.tsx', + 'components/PageEditor.tsx', +]; +const allowedLiteral = new Set(['Title', 'Description', 'Primary heading', 'Summary', 'Body']); + +let failed = false; +for (const file of files) { + const full = join(srcDir, file); + const content = readFileSync(full, 'utf8'); + const jsx = content.split(/return \(/).slice(1).join('\n'); + const textNodes = jsx.match(/>\s*([A-Za-z][^<{]*?)\s*\s*/, '').replace(/\s*<$/, '').trim(); + if (!text || allowedLiteral.has(text)) continue; + if (text.includes('{') || text.includes(';') || text.includes('=')) continue; + if (/^[A-Za-z][A-Za-z .,'-]{3,}$/.test(text)) { + console.error(`${full}: possible hard-coded string "${text}"`); + failed = true; + } + } +} + +if (failed) process.exit(1); +console.log('Administration FE i18n scan passed'); diff --git a/apps/Administration-FE/src/lib/token-store.ts b/apps/Administration-FE/src/lib/token-store.ts new file mode 100644 index 0000000..ed956da --- /dev/null +++ b/apps/Administration-FE/src/lib/token-store.ts @@ -0,0 +1,26 @@ +type TokenPair = { + accessToken: string; + refreshToken: string; +}; + +let tokens: TokenPair | null = null; + +export function setTokens(accessToken: string, refreshToken: string): void { + tokens = { accessToken, refreshToken }; +} + +export function getAccessToken(): string | null { + return tokens?.accessToken ?? null; +} + +export function getRefreshToken(): string | null { + return tokens?.refreshToken ?? null; +} + +export function clearTokens(): void { + tokens = null; +} + +export function hasTokens(): boolean { + return tokens !== null; +} diff --git a/apps/Administration-FE/tests/e2e/admin-auth.spec.ts b/apps/Administration-FE/tests/e2e/admin-auth.spec.ts new file mode 100644 index 0000000..66594e3 --- /dev/null +++ b/apps/Administration-FE/tests/e2e/admin-auth.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from '@playwright/test'; + +const adminEmail = process.env.E2E_ADMIN_EMAIL; +const adminPassword = process.env.E2E_ADMIN_PASSWORD; + +test.skip(!adminEmail || !adminPassword, 'requires E2E_ADMIN_EMAIL and E2E_ADMIN_PASSWORD'); + +test('sign-in success, generic failure, no sign-up, and sign-out', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('heading', { name: /sign in/i })).toBeVisible(); + await expect(page.getByRole('link', { name: /sign up|register|create account/i })).toHaveCount(0); + await expect(page.getByRole('button', { name: /sign up|register|create account/i })).toHaveCount(0); + + await page.getByLabel(/email/i).fill(adminEmail); + await page.getByLabel(/password/i).fill('wrong-password-value'); + await page.getByRole('button', { name: /^sign in$/i }).click(); + await expect(page.getByRole('alert')).toBeVisible(); + const failureText = await page.getByRole('alert').innerText(); + + await page.getByLabel(/email/i).fill('unknown@example.com'); + await page.getByLabel(/password/i).fill('wrong-password-value'); + await page.getByRole('button', { name: /^sign in$/i }).click(); + await expect(page.getByRole('alert')).toHaveText(failureText); + + await page.getByLabel(/email/i).fill(adminEmail); + await page.getByLabel(/password/i).fill(adminPassword); + await page.getByRole('button', { name: /^sign in$/i }).click(); + await expect(page.getByRole('heading', { name: /administration/i })).toBeVisible(); + + await page.getByRole('button', { name: /sign out/i }).click(); + await expect(page.getByRole('heading', { name: /sign in/i })).toBeVisible(); +}); diff --git a/apps/Administration-FE/tests/e2e/admin-rbac-deny.spec.ts b/apps/Administration-FE/tests/e2e/admin-rbac-deny.spec.ts new file mode 100644 index 0000000..8a7f83f --- /dev/null +++ b/apps/Administration-FE/tests/e2e/admin-rbac-deny.spec.ts @@ -0,0 +1,20 @@ +import { test, expect } from '@playwright/test'; + +const editorEmail = process.env.E2E_EDITOR_EMAIL; +const editorPassword = process.env.E2E_EDITOR_PASSWORD; + +test.skip(!editorEmail || !editorPassword, 'requires E2E_EDITOR_EMAIL and E2E_EDITOR_PASSWORD'); + +test('editor can draft but cannot publish', async ({ page }) => { + await page.goto('/'); + await page.getByLabel(/email/i).fill(editorEmail); + await page.getByLabel(/password/i).fill(editorPassword); + await page.getByRole('button', { name: /^sign in$/i }).click(); + await expect(page.getByRole('heading', { name: /administration/i })).toBeVisible(); + await page.getByRole('button', { name: /home page/i }).click(); + await page.getByRole('button', { name: /save draft/i }).click(); + const publish = page.getByRole('button', { name: /^publish$/i }); + await expect(publish).toBeDisabled(); + await expect(publish).toHaveAttribute('aria-disabled', 'true'); + await expect(page.getByText(/you do not have permission/i)).toBeVisible(); +}); diff --git a/apps/Backend/alembic/versions/002_rbac_refresh_session.py b/apps/Backend/alembic/versions/002_rbac_refresh_session.py new file mode 100644 index 0000000..5d1b2cf --- /dev/null +++ b/apps/Backend/alembic/versions/002_rbac_refresh_session.py @@ -0,0 +1,159 @@ +"""RBAC roles, permissions, and refresh-session columns. + +Revision ID: 002 +Revises: 001 +Create Date: 2026-08-14 +""" + +import secrets +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from argon2 import PasswordHasher +from sqlalchemy.dialects import postgresql + +revision: str = "002" +down_revision: Union[str, None] = "001" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_MICROSOFT_ADMIN_COLUMNS = ( + "microsoft_oid", + "tenant_id", + "role", + "updated_at", + "updated_by", +) +_MICROSOFT_SESSION_COLUMNS = ( + "microsoft_access_expires_at", + "microsoft_refresh_token_encrypted", +) + + +def _inspector(): + return sa.inspect(op.get_bind()) + + +def _table_names() -> set[str]: + return set(_inspector().get_table_names()) + + +def _column_names(table: str) -> set[str]: + return {column["name"] for column in _inspector().get_columns(table)} + + +def _index_names(table: str) -> set[str]: + return {index["name"] for index in _inspector().get_indexes(table) if index["name"]} + + +def upgrade() -> None: + tables = _table_names() + if "roles" not in tables: + op.create_table( + "roles", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("name", sa.String(64), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("name", name="uq_roles_name"), + ) + op.create_index("ix_roles_name", "roles", ["name"], unique=True) + + permission_enum = postgresql.ENUM( + "records.view", + "drafts.save", + "records.publish", + name="permission_name", + create_type=False, + ) + permission_enum.create(op.get_bind(), checkfirst=True) + + if "role_permissions" not in tables: + op.create_table( + "role_permissions", + sa.Column("role_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("roles.id"), nullable=False), + sa.Column("permission", permission_enum, nullable=False), + sa.PrimaryKeyConstraint("role_id", "permission", name="pk_role_permissions"), + ) + + if "administrator_roles" not in tables: + op.create_table( + "administrator_roles", + sa.Column( + "administrator_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("administrators.id"), + nullable=False, + ), + sa.Column("role_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("roles.id"), nullable=False), + sa.Column("assigned_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("assigned_by", sa.String(255), nullable=False), + sa.PrimaryKeyConstraint("administrator_id", "role_id", name="pk_administrator_roles"), + ) + + _reconcile_administrators() + _reconcile_admin_sessions() + + +def _reconcile_administrators() -> None: + columns = _column_names("administrators") + if "password_hash" not in columns: + op.add_column("administrators", sa.Column("password_hash", sa.String(255), nullable=True)) + locked = PasswordHasher().hash(secrets.token_urlsafe(32)) + op.execute( + sa.text("UPDATE administrators SET password_hash = :pw_hash WHERE password_hash IS NULL").bindparams( + pw_hash=locked + ) + ) + op.alter_column("administrators", "password_hash", nullable=False) + + indexes = _index_names("administrators") + if "ix_administrators_microsoft_oid" in indexes: + op.drop_index("ix_administrators_microsoft_oid", table_name="administrators") + + columns = _column_names("administrators") + for column in _MICROSOFT_ADMIN_COLUMNS: + if column in columns: + op.drop_column("administrators", column) + + +def _reconcile_admin_sessions() -> None: + columns = _column_names("admin_sessions") + if "refresh_token_hash" not in columns: + op.add_column("admin_sessions", sa.Column("refresh_token_hash", sa.String(255), nullable=True)) + if "family_id" not in columns: + op.add_column("admin_sessions", sa.Column("family_id", postgresql.UUID(as_uuid=True), nullable=True)) + + columns = _column_names("admin_sessions") + if "token_hash" in columns: + op.execute("UPDATE admin_sessions SET refresh_token_hash = token_hash WHERE refresh_token_hash IS NULL") + op.execute("UPDATE admin_sessions SET family_id = gen_random_uuid() WHERE family_id IS NULL") + op.alter_column("admin_sessions", "refresh_token_hash", nullable=False) + op.alter_column("admin_sessions", "family_id", nullable=False) + + indexes = _index_names("admin_sessions") + if "ix_admin_sessions_refresh_token_hash" not in indexes: + op.create_index("ix_admin_sessions_refresh_token_hash", "admin_sessions", ["refresh_token_hash"]) + if "ix_admin_sessions_token_hash" in indexes: + op.drop_index("ix_admin_sessions_token_hash", table_name="admin_sessions") + if "token_hash" in columns: + op.drop_column("admin_sessions", "token_hash") + + columns = _column_names("admin_sessions") + for column in _MICROSOFT_SESSION_COLUMNS: + if column in columns: + op.drop_column("admin_sessions", column) + + +def downgrade() -> None: + op.add_column("admin_sessions", sa.Column("token_hash", sa.String(255), nullable=True)) + op.execute("UPDATE admin_sessions SET token_hash = refresh_token_hash") + op.alter_column("admin_sessions", "token_hash", nullable=False) + op.create_index("ix_admin_sessions_token_hash", "admin_sessions", ["token_hash"]) + op.drop_index("ix_admin_sessions_refresh_token_hash", table_name="admin_sessions") + op.drop_column("admin_sessions", "family_id") + op.drop_column("admin_sessions", "refresh_token_hash") + op.drop_table("administrator_roles") + op.drop_table("role_permissions") + op.drop_table("roles") + op.execute("DROP TYPE IF EXISTS permission_name") diff --git a/apps/Backend/src/flycatch_api/cli/bootstrap.py b/apps/Backend/src/flycatch_api/cli/bootstrap.py new file mode 100644 index 0000000..5ba133d --- /dev/null +++ b/apps/Backend/src/flycatch_api/cli/bootstrap.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import argparse +import getpass +import sys + +from flycatch_api.db import SessionLocal +from flycatch_api.services.bootstrap_service import ( + ROLE_ADMINISTRATOR, + ROLE_EDITOR, + BootstrapError, + BootstrapService, + BootstrapUser, +) + + +def _prompt_secret(label: str) -> str: + return getpass.getpass(f"{label} (min 12 chars): ") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Create default roles and at least two administrative users" + ) + parser.add_argument("--user-1-email", required=True) + parser.add_argument("--user-1-password", default=None) + parser.add_argument("--user-2-email", required=True) + parser.add_argument("--user-2-password", default=None) + parser.add_argument( + "--user-2-role", + choices=[ROLE_ADMINISTRATOR, ROLE_EDITOR], + default=ROLE_ADMINISTRATOR, + ) + parser.add_argument("--created-by", default="cli") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + password_1 = args.user_1_password or _prompt_secret("User 1 password") + password_2 = args.user_2_password or _prompt_secret("User 2 password") + + db = SessionLocal() + try: + result = BootstrapService().run( + db, + BootstrapUser( + email=args.user_1_email, + password=password_1, + role=ROLE_ADMINISTRATOR, + ), + BootstrapUser( + email=args.user_2_email, + password=password_2, + role=args.user_2_role, + ), + created_by=args.created_by, + ) + print(result.summary()) + print(f"user 1: {args.user_1_email.lower()} role={ROLE_ADMINISTRATOR}") + print(f"user 2: {args.user_2_email.lower()} role={args.user_2_role}") + return 0 + except BootstrapError as exc: + print(str(exc), file=sys.stderr) + return 1 + finally: + db.close() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/apps/Backend/src/flycatch_api/models/administrator_role.py b/apps/Backend/src/flycatch_api/models/administrator_role.py new file mode 100644 index 0000000..e794705 --- /dev/null +++ b/apps/Backend/src/flycatch_api/models/administrator_role.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import DateTime, ForeignKey, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from flycatch_api.db import Base + +if TYPE_CHECKING: + from flycatch_api.models.administrator import Administrator + from flycatch_api.models.role import Role + + +class AdministratorRole(Base): + __tablename__ = "administrator_roles" + + administrator_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("administrators.id"), primary_key=True + ) + role_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("roles.id"), primary_key=True + ) + assigned_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + assigned_by: Mapped[str] = mapped_column(String(255), nullable=False) + + administrator: Mapped[Administrator] = relationship(back_populates="role_assignments") + role: Mapped[Role] = relationship(back_populates="administrator_roles") diff --git a/apps/Backend/src/flycatch_api/models/role.py b/apps/Backend/src/flycatch_api/models/role.py new file mode 100644 index 0000000..ccc6ddb --- /dev/null +++ b/apps/Backend/src/flycatch_api/models/role.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import DateTime, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from flycatch_api.db import Base + +if TYPE_CHECKING: + from flycatch_api.models.administrator_role import AdministratorRole + from flycatch_api.models.role_permission import RolePermission + + +class Role(Base): + __tablename__ = "roles" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + permissions: Mapped[list[RolePermission]] = relationship(back_populates="role") + administrator_roles: Mapped[list[AdministratorRole]] = relationship(back_populates="role") diff --git a/apps/Backend/src/flycatch_api/models/role_permission.py b/apps/Backend/src/flycatch_api/models/role_permission.py new file mode 100644 index 0000000..e341f88 --- /dev/null +++ b/apps/Backend/src/flycatch_api/models/role_permission.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import enum +import uuid +from typing import TYPE_CHECKING + +from sqlalchemy import Enum, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from flycatch_api.db import Base + +if TYPE_CHECKING: + from flycatch_api.models.role import Role + + +class PermissionName(str, enum.Enum): + records_view = "records.view" + drafts_save = "drafts.save" + records_publish = "records.publish" + + +class RolePermission(Base): + __tablename__ = "role_permissions" + + role_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("roles.id"), primary_key=True + ) + permission: Mapped[PermissionName] = mapped_column( + Enum( + PermissionName, + name="permission_name", + values_callable=lambda members: [member.value for member in members], + ), + primary_key=True, + ) + + role: Mapped[Role] = relationship(back_populates="permissions") diff --git a/apps/Backend/src/flycatch_api/schemas/admin_auth.py b/apps/Backend/src/flycatch_api/schemas/admin_auth.py new file mode 100644 index 0000000..fd210cc --- /dev/null +++ b/apps/Backend/src/flycatch_api/schemas/admin_auth.py @@ -0,0 +1,62 @@ +from datetime import datetime +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, EmailStr, Field + + +class SignInRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + email: EmailStr + password: str = Field(min_length=1) + + +class RefreshRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + refresh_token: str = Field(min_length=1) + + +class SignOutRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + refresh_token: str | None = Field(default=None, min_length=1) + + +class SessionContext(BaseModel): + model_config = ConfigDict(extra="forbid") + + administrator_id: UUID + email: EmailStr + roles: list[str] + permissions: list[str] + idle_expires_at: datetime + absolute_expires_at: datetime + + +class TokenPair(BaseModel): + model_config = ConfigDict(extra="forbid") + + access_token: str = Field(min_length=1) + refresh_token: str = Field(min_length=1) + token_type: Literal["bearer"] = "bearer" + expires_in: int = Field(ge=1) + session: SessionContext + + +class AuthError(BaseModel): + model_config = ConfigDict(extra="forbid") + + code: Literal["unauthenticated", "invalid_credentials"] + message_key: str + + +class FieldErrorDetail(BaseModel): + message_key: str + + +class FieldErrors(BaseModel): + model_config = ConfigDict(extra="forbid") + + fields: dict[str, FieldErrorDetail] diff --git a/apps/Backend/src/flycatch_api/schemas/admin_rbac.py b/apps/Backend/src/flycatch_api/schemas/admin_rbac.py new file mode 100644 index 0000000..f3ca747 --- /dev/null +++ b/apps/Backend/src/flycatch_api/schemas/admin_rbac.py @@ -0,0 +1,13 @@ +from typing import Literal + +from pydantic import BaseModel, ConfigDict + +from flycatch_api.models.role_permission import PermissionName + + +class PermissionDenied(BaseModel): + model_config = ConfigDict(extra="forbid") + + code: Literal["permission_denied"] = "permission_denied" + message_key: Literal["admin.action.forbidden"] = "admin.action.forbidden" + permission: PermissionName diff --git a/apps/Backend/src/flycatch_api/security/jwt.py b/apps/Backend/src/flycatch_api/security/jwt.py new file mode 100644 index 0000000..3d0d220 --- /dev/null +++ b/apps/Backend/src/flycatch_api/security/jwt.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import UUID, uuid4 + +import jwt +from jwt import InvalidTokenError + +from flycatch_api.config import settings + +ACCESS_TOKEN_TYPE = "access" + + +class JwtError(Exception): + """Access JWT is missing, expired, or otherwise invalid.""" + + +def issue_access_token(*, subject: UUID, session_id: UUID) -> str: + now = datetime.now(UTC) + payload = { + "sub": str(subject), + "sid": str(session_id), + "typ": ACCESS_TOKEN_TYPE, + "iat": now, + "exp": now + timedelta(minutes=settings.jwt_access_minutes), + "jti": str(uuid4()), + } + return jwt.encode(payload, settings.jwt_secret, algorithm="HS256") + + +def _validate_claims(payload: dict) -> dict: + if payload.get("typ") != ACCESS_TOKEN_TYPE: + raise JwtError("invalid access token type") + if "sub" not in payload or "sid" not in payload: + raise JwtError("invalid access token claims") + if "roles" in payload or "permissions" in payload: + raise JwtError("access token must not contain roles or permissions") + return payload + + +def verify_access_token(token: str) -> dict: + try: + payload = jwt.decode(token, settings.jwt_secret, algorithms=["HS256"]) + except InvalidTokenError as exc: + raise JwtError("invalid access token") from exc + return _validate_claims(payload) + + +def decode_access_token_allow_expired(token: str) -> dict: + try: + payload = jwt.decode( + token, + settings.jwt_secret, + algorithms=["HS256"], + options={"verify_exp": False}, + ) + except InvalidTokenError as exc: + raise JwtError("invalid access token") from exc + return _validate_claims(payload) + + +def access_expires_in_seconds() -> int: + return settings.jwt_access_minutes * 60 diff --git a/apps/Backend/src/flycatch_api/services/bootstrap_service.py b/apps/Backend/src/flycatch_api/services/bootstrap_service.py new file mode 100644 index 0000000..e99594b --- /dev/null +++ b/apps/Backend/src/flycatch_api/services/bootstrap_service.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime + +from sqlalchemy.orm import Session + +from flycatch_api.models import ( + Administrator, + AdministratorRole, + PermissionName, + Role, + RolePermission, +) +from flycatch_api.security.password import hash_password + +ROLE_ADMINISTRATOR = "administrator" +ROLE_EDITOR = "editor" +CATALOGUE_ROLES = (ROLE_ADMINISTRATOR, ROLE_EDITOR) +CATALOGUE_PERMISSIONS: dict[str, tuple[PermissionName, ...]] = { + ROLE_ADMINISTRATOR: ( + PermissionName.records_view, + PermissionName.drafts_save, + PermissionName.records_publish, + ), + ROLE_EDITOR: ( + PermissionName.records_view, + PermissionName.drafts_save, + ), +} + + +class BootstrapError(ValueError): + """Fail-closed bootstrap input or state error.""" + + +@dataclass(frozen=True) +class BootstrapUser: + email: str + password: str + role: str + + +@dataclass +class BootstrapResult: + created_roles: list[str] + created_users: list[str] + assigned: list[str] + already_existed: bool + + def summary(self) -> str: + if self.already_existed and not self.created_users and not self.created_roles: + return "Defaults already exist" + parts = [] + if self.created_roles: + parts.append(f"created roles: {', '.join(self.created_roles)}") + if self.created_users: + parts.append(f"created users: {', '.join(self.created_users)}") + if self.assigned: + parts.append(f"assigned roles: {', '.join(self.assigned)}") + return "; ".join(parts) if parts else "Defaults already exist" + + +class BootstrapService: + def run( + self, + db: Session, + user_1: BootstrapUser, + user_2: BootstrapUser, + created_by: str = "cli", + ) -> BootstrapResult: + self._validate(user_1, user_2) + result = BootstrapResult( + created_roles=[], + created_users=[], + assigned=[], + already_existed=True, + ) + try: + self._ensure_catalogue(db, result) + self._ensure_user(db, user_1, created_by, result) + self._ensure_user(db, user_2, created_by, result) + db.commit() + except Exception: + db.rollback() + raise + result.already_existed = not (result.created_roles or result.created_users or result.assigned) + return result + + def _validate(self, user_1: BootstrapUser, user_2: BootstrapUser) -> None: + if user_1.role != ROLE_ADMINISTRATOR: + raise BootstrapError("User 1 must be assigned administrator") + if user_2.role not in CATALOGUE_ROLES: + raise BootstrapError("User 2 role must be administrator or editor") + email_1 = user_1.email.strip().lower() + email_2 = user_2.email.strip().lower() + if not email_1 or not email_2: + raise BootstrapError("Both user emails are required") + if email_1 == email_2: + raise BootstrapError("User emails must be distinct") + if len(user_1.password) < 12 or len(user_2.password) < 12: + raise BootstrapError("Passwords must be at least 12 characters") + + def _ensure_catalogue(self, db: Session, result: BootstrapResult) -> None: + now = datetime.now(UTC) + for name, permissions in CATALOGUE_PERMISSIONS.items(): + role = db.query(Role).filter(Role.name == name).first() + if role is None: + role = Role(name=name, created_at=now) + db.add(role) + db.flush() + result.created_roles.append(name) + existing = {grant.permission for grant in role.permissions} + for permission in permissions: + if permission not in existing: + db.add(RolePermission(role_id=role.id, permission=permission)) + if name not in result.created_roles: + result.created_roles.append(f"{name}:{permission.value}") + + def _ensure_user( + self, + db: Session, + user: BootstrapUser, + created_by: str, + result: BootstrapResult, + ) -> None: + email = user.email.strip().lower() + admin = db.query(Administrator).filter(Administrator.email == email).first() + if admin is None: + admin = Administrator( + email=email, + password_hash=hash_password(user.password), + is_active=True, + created_at=datetime.now(UTC), + created_by=created_by, + ) + db.add(admin) + db.flush() + result.created_users.append(email) + + role = db.query(Role).filter(Role.name == user.role).first() + if role is None: + raise BootstrapError(f"Role {user.role} is missing from the catalogue") + + assignment = ( + db.query(AdministratorRole) + .filter( + AdministratorRole.administrator_id == admin.id, + AdministratorRole.role_id == role.id, + ) + .first() + ) + if assignment is None: + db.add( + AdministratorRole( + administrator_id=admin.id, + role_id=role.id, + assigned_at=datetime.now(UTC), + assigned_by=created_by, + ) + ) + result.assigned.append(f"{email}:{user.role}") diff --git a/apps/Backend/src/flycatch_api/services/rbac_service.py b/apps/Backend/src/flycatch_api/services/rbac_service.py new file mode 100644 index 0000000..386f9f2 --- /dev/null +++ b/apps/Backend/src/flycatch_api/services/rbac_service.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.orm import Session + +from flycatch_api.models import AdministratorRole, PermissionName, Role, RolePermission + + +class RbacService: + def role_names(self, db: Session, administrator_id: UUID) -> list[str]: + rows = ( + db.query(Role.name) + .join(AdministratorRole, AdministratorRole.role_id == Role.id) + .filter(AdministratorRole.administrator_id == administrator_id) + .all() + ) + return sorted({name for (name,) in rows}) + + def permissions(self, db: Session, administrator_id: UUID) -> list[str]: + rows = ( + db.query(RolePermission.permission) + .join(Role, Role.id == RolePermission.role_id) + .join(AdministratorRole, AdministratorRole.role_id == Role.id) + .filter(AdministratorRole.administrator_id == administrator_id) + .all() + ) + values = { + permission.value if isinstance(permission, PermissionName) else str(permission) + for (permission,) in rows + } + return sorted(values) + + def has_permission( + self, db: Session, administrator_id: UUID, permission: PermissionName | str + ) -> bool: + required = permission.value if isinstance(permission, PermissionName) else permission + return required in self.permissions(db, administrator_id) diff --git a/apps/Backend/tests/integration/test_admin_auth.py b/apps/Backend/tests/integration/test_admin_auth.py new file mode 100644 index 0000000..3c98033 --- /dev/null +++ b/apps/Backend/tests/integration/test_admin_auth.py @@ -0,0 +1,124 @@ +from flycatch_api.models import AdminSession +from flycatch_api.security.session import hash_token + + +def test_sign_in_returns_token_pair(client, bootstrapped): + response = client.post( + "/api/v1/admin/auth/sign-in", + json={"email": bootstrapped["admin_email"], "password": bootstrapped["admin_password"]}, + ) + assert response.status_code == 200 + body = response.json() + assert body["token_type"] == "bearer" + assert body["access_token"] + assert body["refresh_token"] + assert body["expires_in"] >= 1 + assert "administrator" in body["session"]["roles"] + assert "records.publish" in body["session"]["permissions"] + + +def test_generic_failure_creates_no_session(client, bootstrapped, db): + wrong = client.post( + "/api/v1/admin/auth/sign-in", + json={"email": bootstrapped["admin_email"], "password": "not-the-password"}, + ) + unknown = client.post( + "/api/v1/admin/auth/sign-in", + json={"email": "missing@example.com", "password": "administrator-pass"}, + ) + assert wrong.status_code == 401 + assert unknown.status_code == 401 + assert wrong.json() == unknown.json() + assert wrong.json()["code"] == "invalid_credentials" + assert wrong.json()["message_key"] == "admin.sign_in.error" + assert db.query(AdminSession).count() == 0 + + +def test_inactive_user_looks_like_unknown(client, bootstrapped, db): + from flycatch_api.models import Administrator + + admin = db.query(Administrator).filter_by(email=bootstrapped["admin_email"]).one() + admin.is_active = False + db.commit() + inactive = client.post( + "/api/v1/admin/auth/sign-in", + json={"email": bootstrapped["admin_email"], "password": bootstrapped["admin_password"]}, + ) + unknown = client.post( + "/api/v1/admin/auth/sign-in", + json={"email": "missing@example.com", "password": "administrator-pass"}, + ) + assert inactive.status_code == 401 + assert inactive.json() == unknown.json() + assert db.query(AdminSession).count() == 0 + + +def test_refresh_rotates_and_reuse_revokes_family(client, bootstrapped, db): + first = client.post( + "/api/v1/admin/auth/sign-in", + json={"email": bootstrapped["admin_email"], "password": bootstrapped["admin_password"]}, + ).json() + rotated = client.post( + "/api/v1/admin/auth/refresh", + json={"refresh_token": first["refresh_token"]}, + ) + assert rotated.status_code == 200 + body = rotated.json() + assert body["refresh_token"] != first["refresh_token"] + reuse = client.post( + "/api/v1/admin/auth/refresh", + json={"refresh_token": first["refresh_token"]}, + ) + assert reuse.status_code == 401 + again = client.post( + "/api/v1/admin/auth/refresh", + json={"refresh_token": body["refresh_token"]}, + ) + assert again.status_code == 401 + assert all(row.revoked_at is not None for row in db.query(AdminSession).all()) + + +def test_idle_and_absolute_expiry_reject_refresh(client, bootstrapped, db): + from datetime import UTC, datetime, timedelta + + tokens = client.post( + "/api/v1/admin/auth/sign-in", + json={"email": bootstrapped["admin_email"], "password": bootstrapped["admin_password"]}, + ).json() + session = ( + db.query(AdminSession) + .filter(AdminSession.refresh_token_hash == hash_token(tokens["refresh_token"])) + .one() + ) + session.idle_expires_at = datetime.now(UTC) - timedelta(seconds=1) + db.commit() + idle = client.post("/api/v1/admin/auth/refresh", json={"refresh_token": tokens["refresh_token"]}) + assert idle.status_code == 401 + + tokens = client.post( + "/api/v1/admin/auth/sign-in", + json={"email": bootstrapped["admin_email"], "password": bootstrapped["admin_password"]}, + ).json() + session = ( + db.query(AdminSession) + .filter(AdminSession.refresh_token_hash == hash_token(tokens["refresh_token"])) + .one() + ) + session.absolute_expires_at = datetime.now(UTC) - timedelta(seconds=1) + db.commit() + absolute = client.post( + "/api/v1/admin/auth/refresh", json={"refresh_token": tokens["refresh_token"]} + ) + assert absolute.status_code == 401 + + +def test_sign_out_revokes_and_session_is_401(client, bootstrapped): + tokens = client.post( + "/api/v1/admin/auth/sign-in", + json={"email": bootstrapped["admin_email"], "password": bootstrapped["admin_password"]}, + ).json() + headers = {"Authorization": f"Bearer {tokens['access_token']}"} + out = client.post("/api/v1/admin/auth/sign-out", headers=headers) + assert out.status_code == 204 + session = client.get("/api/v1/admin/auth/session", headers=headers) + assert session.status_code == 401 diff --git a/apps/Backend/tests/integration/test_bootstrap.py b/apps/Backend/tests/integration/test_bootstrap.py new file mode 100644 index 0000000..c611abd --- /dev/null +++ b/apps/Backend/tests/integration/test_bootstrap.py @@ -0,0 +1,110 @@ +from io import StringIO +from unittest.mock import patch + +from flycatch_api.cli.bootstrap import main +from flycatch_api.models import Administrator, AdministratorRole, PermissionName, Role, RolePermission +from flycatch_api.services.bootstrap_service import ( + BootstrapError, + BootstrapService, + BootstrapUser, +) + + +def test_bootstrap_creates_two_users_and_catalogue(db): + result = BootstrapService().run( + db, + BootstrapUser("admin1@example.com", "administrator-pass", "administrator"), + BootstrapUser("editor1@example.com", "editor-password", "editor"), + ) + assert sorted(result.created_users) == ["admin1@example.com", "editor1@example.com"] + roles = {role.name: {grant.permission for grant in role.permissions} for role in db.query(Role).all()} + assert roles["administrator"] == { + PermissionName.records_view, + PermissionName.drafts_save, + PermissionName.records_publish, + } + assert roles["editor"] == {PermissionName.records_view, PermissionName.drafts_save} + assert PermissionName.records_publish not in roles["editor"] + assert db.query(Administrator).count() == 2 + + +def test_bootstrap_is_idempotent(db): + users = ( + BootstrapUser("admin1@example.com", "administrator-pass", "administrator"), + BootstrapUser("editor1@example.com", "editor-password", "editor"), + ) + BootstrapService().run(db, *users) + second = BootstrapService().run(db, *users) + assert second.created_users == [] + assert second.created_roles == [] + assert "already exist" in second.summary().lower() or second.already_existed + assert db.query(Administrator).count() == 2 + assert db.query(Role).count() == 2 + assert db.query(RolePermission).count() == 5 + + +def test_bootstrap_creates_missing_user_of_the_pair(db): + BootstrapService().run( + db, + BootstrapUser("admin1@example.com", "administrator-pass", "administrator"), + BootstrapUser("editor1@example.com", "editor-password", "editor"), + ) + editor = db.query(Administrator).filter(Administrator.email == "editor1@example.com").one() + db.query(AdministratorRole).filter( + AdministratorRole.administrator_id == editor.id + ).delete() + db.delete(editor) + db.commit() + result = BootstrapService().run( + db, + BootstrapUser("admin1@example.com", "administrator-pass", "administrator"), + BootstrapUser("editor1@example.com", "editor-password", "editor"), + ) + assert result.created_users == ["editor1@example.com"] + assert db.query(Administrator).count() == 2 + + +def test_bootstrap_fail_closed_missing_inputs(db): + try: + BootstrapService().run( + db, + BootstrapUser("admin1@example.com", "short", "administrator"), + BootstrapUser("editor1@example.com", "editor-password", "editor"), + ) + raise AssertionError("expected BootstrapError") + except BootstrapError: + pass + assert db.query(Administrator).count() == 0 + assert db.query(Role).count() == 0 + + +def test_bootstrap_cli_does_not_print_secrets(db, monkeypatch): + class _Session: + def close(self): + return None + + def __getattr__(self, name): + return getattr(db, name) + + monkeypatch.setattr("flycatch_api.cli.bootstrap.SessionLocal", lambda: _Session()) + stdout = StringIO() + with patch("sys.stdout", stdout): + code = main( + [ + "--user-1-email", + "admin1@example.com", + "--user-1-password", + "administrator-pass", + "--user-2-email", + "editor1@example.com", + "--user-2-password", + "editor-password", + "--user-2-role", + "editor", + ] + ) + assert code == 0 + output = stdout.getvalue() + assert "administrator-pass" not in output + assert "editor-password" not in output + assert "admin1@example.com" in output diff --git a/apps/Backend/tests/integration/test_rbac_deny.py b/apps/Backend/tests/integration/test_rbac_deny.py new file mode 100644 index 0000000..c788959 --- /dev/null +++ b/apps/Backend/tests/integration/test_rbac_deny.py @@ -0,0 +1,55 @@ +from flycatch_api.models import ManagedRecord, RecordType + + +def _bearer(client, email, password): + tokens = client.post( + "/api/v1/admin/auth/sign-in", + json={"email": email, "password": password}, + ).json() + return tokens, {"Authorization": f"Bearer {tokens['access_token']}"} + + +def test_editor_can_draft_but_direct_publish_is_403(client, bootstrapped, seeded_records, db): + tokens, headers = _bearer(client, bootstrapped["editor_email"], bootstrapped["editor_password"]) + page = client.get("/api/v1/admin/pages/home", headers=headers) + assert page.status_code == 200 + draft = client.patch("/api/v1/admin/pages/home", headers=headers, json=page.json()["draft"]) + assert draft.status_code == 200 + + before = ( + db.query(ManagedRecord) + .filter(ManagedRecord.type == RecordType.page, ManagedRecord.slug == "home") + .one() + .published_at + ) + denied = client.post( + "/api/v1/admin/publish", + headers=headers, + json={"type": "page", "slug": "home"}, + ) + assert denied.status_code == 403 + body = denied.json() + assert body["code"] == "permission_denied" + assert body["message_key"] == "admin.action.forbidden" + assert body["permission"] == "records.publish" + after = ( + db.query(ManagedRecord) + .filter(ManagedRecord.type == RecordType.page, ManagedRecord.slug == "home") + .one() + .published_at + ) + assert after == before + + still_signed_in = client.get("/api/v1/admin/auth/session", headers=headers) + assert still_signed_in.status_code == 200 + assert still_signed_in.json()["email"] == bootstrapped["editor_email"] + + +def test_unauthenticated_publish_is_401_not_403(client, seeded_records): + response = client.post("/api/v1/admin/publish", json={"type": "page", "slug": "home"}) + assert response.status_code == 401 + body = response.json() + assert body["code"] == "unauthenticated" + assert "roles" not in body + assert "permissions" not in body + assert "email" not in body diff --git a/apps/Backend/tests/integration/test_rbac_grant.py b/apps/Backend/tests/integration/test_rbac_grant.py new file mode 100644 index 0000000..ca1496e --- /dev/null +++ b/apps/Backend/tests/integration/test_rbac_grant.py @@ -0,0 +1,63 @@ +from flycatch_api.models import Administrator, AdministratorRole, Role +from flycatch_api.services.bootstrap_service import BootstrapService, BootstrapUser + + +def _bearer(client, email, password): + tokens = client.post( + "/api/v1/admin/auth/sign-in", + json={"email": email, "password": password}, + ).json() + return {"Authorization": f"Bearer {tokens['access_token']}"} + + +def test_administrator_can_view_draft_and_publish(client, bootstrapped, seeded_records): + headers = _bearer(client, bootstrapped["admin_email"], bootstrapped["admin_password"]) + view = client.get("/api/v1/admin/pages/home", headers=headers) + assert view.status_code == 200 + draft = client.patch( + "/api/v1/admin/pages/home", + headers=headers, + json=view.json()["draft"], + ) + assert draft.status_code == 200 + publish = client.post( + "/api/v1/admin/publish", + headers=headers, + json={"type": "page", "slug": "home"}, + ) + assert publish.status_code == 200 + + +def test_multi_role_union_can_publish(client, db, seeded_records): + BootstrapService().run( + db, + BootstrapUser("admin1@example.com", "administrator-pass", "administrator"), + BootstrapUser("both@example.com", "both-user-pass", "editor"), + created_by="test", + ) + user = db.query(Administrator).filter_by(email="both@example.com").one() + admin_role = db.query(Role).filter_by(name="administrator").one() + db.add( + AdministratorRole( + administrator_id=user.id, + role_id=admin_role.id, + assigned_at=user.created_at, + assigned_by="test", + ) + ) + db.commit() + headers = _bearer(client, "both@example.com", "both-user-pass") + session = client.get("/api/v1/admin/auth/session", headers=headers).json() + assert set(session["roles"]) == {"administrator", "editor"} + assert "records.publish" in session["permissions"] + page = client.get("/api/v1/admin/pages/home", headers=headers) + assert page.status_code == 200 + assert client.patch("/api/v1/admin/pages/home", headers=headers, json=page.json()["draft"]).status_code == 200 + assert ( + client.post( + "/api/v1/admin/publish", + headers=headers, + json={"type": "page", "slug": "home"}, + ).status_code + == 200 + ) diff --git a/apps/Frontend/.prettierignore b/apps/Frontend/.prettierignore new file mode 100644 index 0000000..01c31d8 --- /dev/null +++ b/apps/Frontend/.prettierignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +build/ +.astro/ +coverage/ +src/generated/ +package-lock.json +yarn.lock +pnpm-lock.yaml diff --git a/specs/002-auth-rbac/tasks.md b/specs/002-auth-rbac/tasks.md new file mode 100644 index 0000000..489431a --- /dev/null +++ b/specs/002-auth-rbac/tasks.md @@ -0,0 +1,290 @@ +--- +description: "Task list for Authentication and Authorisation (RBAC) feature implementation" +--- + +# Tasks: Authentication and Authorisation (RBAC) + +**Input**: Design documents from `/specs/002-auth-rbac/` + +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/, quickstart.md + +**Tests**: FR-030 and constitution XIII require quality gates (bootstrap, sign-in success/generic failure, grant, deny including a direct request, contract validation). TDD is not mandated. Test tasks appear as verification steps at the end of each story phase. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +## Path Conventions + +- **Administration FE**: `apps/Administration-FE/src/` +- **Backend**: `apps/Backend/src/flycatch_api/` +- **Frontend**: `apps/Frontend/` (non-regression only) +- **Deployment**: `deployment/` +- **Contracts**: `specs/002-auth-rbac/contracts/` + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Wire JWT/RBAC dependencies, secrets, contract consumers, and CLI entry points onto the existing three-app monorepo + +- [x] T001 Add `PyJWT` to Backend dependencies in `apps/Backend/pyproject.toml` +- [x] T002 [P] Add `jwt_secret` and `jwt_access_minutes` (default 15) settings in `apps/Backend/src/flycatch_api/config.py` +- [x] T003 [P] Add `JWT_SECRET` and `JWT_ACCESS_MINUTES` placeholders (no well-known staff passwords) in `deployment/.env.example` +- [x] T004 [P] Point Administration FE OpenAPI generation at `admin-auth.v2`, `admin-rbac.v1`, `admin-management.v2`, and `publish.v2` in `apps/Administration-FE/scripts/generate-client.mjs` and `apps/Administration-FE/package.json` +- [x] T005 [P] Extend `scripts/validate-contracts.mjs` to validate OpenAPI YAML under `specs/002-auth-rbac/contracts/` (skip `bootstrap.cli.yaml`) +- [x] T006 Register `flycatch-bootstrap` console script in `apps/Backend/pyproject.toml` +- [x] T007 [P] Add any missing sign-in, session-expired, and permission-denied message keys in `apps/Administration-FE/src/i18n/en.json` + +**Checkpoint**: Backend can import PyJWT; contract validator covers 002 OpenAPI files; Administration FE generate script targets 002 contracts + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Roles, refresh-session schema, JWT helpers, request-time RBAC, and in-memory token storage that ALL user stories require + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [x] T008 Create Alembic migration for `roles`, `role_permissions`, `administrator_roles`, and refresh-session columns (`refresh_token_hash`, `family_id`) on `admin_sessions` in `apps/Backend/alembic/versions/002_rbac_refresh_session.py` +- [x] T009 [P] Implement SQLAlchemy `Role` model (`name` unique slug) in `apps/Backend/src/flycatch_api/models/role.py` +- [x] T010 [P] Implement SQLAlchemy `RolePermission` model (permission enum `records.view` / `drafts.save` / `records.publish`) in `apps/Backend/src/flycatch_api/models/role_permission.py` +- [x] T011 [P] Implement SQLAlchemy `AdministratorRole` model in `apps/Backend/src/flycatch_api/models/administrator_role.py` +- [x] T012 Evolve `AdminSession` into the hashed refresh-token session (`refresh_token_hash`, `family_id`, idle 30 minutes, absolute 12 hours) in `apps/Backend/src/flycatch_api/models/admin_session.py` +- [x] T013 Wire new models and `Administrator` relationships in `apps/Backend/src/flycatch_api/models/__init__.py` and `apps/Backend/src/flycatch_api/models/administrator.py` +- [x] T014 Create Pydantic schemas for `TokenPair`, `SessionContext`, `AuthError`, and `PermissionDenied` aligned to 002 contracts in `apps/Backend/src/flycatch_api/schemas/admin_auth.py` and `apps/Backend/src/flycatch_api/schemas/admin_rbac.py` +- [x] T015 [P] Implement HS256 access-JWT issue/verify (`sub`, `sid`, `typ=access`, `iat`, `exp`, `jti`; no roles or permissions) in `apps/Backend/src/flycatch_api/security/jwt.py` +- [x] T016 Implement request-time RBAC (union of assigned role permissions; current assignments only) in `apps/Backend/src/flycatch_api/services/rbac_service.py` +- [x] T017 Replace cookie/CSRF staff auth with Bearer principal plus `require_permission` in `apps/Backend/src/flycatch_api/security/dependencies.py` +- [x] T018 Remove Administration CSRF route and cookie-session staff mechanism from `apps/Backend/src/flycatch_api/api/admin_management.py`, `apps/Backend/src/flycatch_api/security/csrf.py`, and `apps/Backend/src/flycatch_api/security/session.py` +- [x] T019 Add JWT + hashed-refresh primitives (issue pair, rotate, family revoke, hash with `session_secret`) in `apps/Backend/src/flycatch_api/services/auth_service.py` +- [x] T020 [P] Implement in-memory access + refresh token store (no `localStorage`, `sessionStorage`, or cookies) in `apps/Administration-FE/src/lib/token-store.ts` + +**Checkpoint**: Migration applies; JWT can be issued and verified; RBAC can compute a permission union; Foundation cookie/CSRF is no longer the staff auth path + +--- + +## Phase 3: User Story 1 — Operator bootstraps default users and roles (Priority: P1) 🎯 MVP + +**Goal**: An operator can run `flycatch-bootstrap` once to create Administrator and Editor roles plus at least two staff users with assignments, idempotently and without leaking secrets + +**Independent Test**: On an empty environment, run bootstrap with two identities and secrets; confirm two active users, roles `administrator` (view+draft+publish) and `editor` (view+draft only), and assignments. Re-run with the same emails and confirm zero duplicates. There is no sign-up screen (quickstart V1) + +### Implementation for User Story 1 + +- [x] T021 [US1] Implement fail-closed, idempotent bootstrap (default roles, ≥2 users, user 1 always `administrator`, user 2 role flag default `administrator`) in `apps/Backend/src/flycatch_api/services/bootstrap_service.py` +- [x] T022 [US1] Implement `flycatch-bootstrap` CLI (flags/prompts per `bootstrap.cli.yaml`; never log secrets) in `apps/Backend/src/flycatch_api/cli/bootstrap.py` +- [x] T023 [US1] Require `--role` (`administrator` | `editor`) on later provisioning in `apps/Backend/src/flycatch_api/cli/provision_admin.py` +- [x] T024 [US1] Document bootstrap and required `--role` provisioning in `docs/onboarding.md` +- [x] T025 [US1] Add integration tests for two users, catalogue permissions, idempotent re-run, partial-user create, fail-closed missing inputs, and no secrets in stdout in `apps/Backend/tests/integration/test_bootstrap.py` + +**Checkpoint**: `flycatch-bootstrap` creates the default set; a second run reports defaults already exist; Editor never receives `records.publish` + +--- + +## Phase 4: User Story 2 — Provisioned staff member signs in with a password (Priority: P1) + +**Goal**: Bootstrapped staff sign in with email/password, receive access + refresh tokens in memory, reach the workspace without a full-page reload, and sign out; failures stay generic and create no session + +**Independent Test**: Sign in with a valid bootstrapped account (200 + both tokens + session roles/permissions). Retry with a wrong password and an unknown email — same generic error, zero sessions. Confirm no sign-up control. Sign out and confirm further admin requests are 401 (quickstart V2, V3) + +### Implementation for User Story 2 + +- [x] T026 [US2] Implement `POST /admin/auth/sign-in`, `POST /admin/auth/refresh`, `POST /admin/auth/sign-out`, and `GET /admin/auth/session` per `admin-auth.v2.yaml` in `apps/Backend/src/flycatch_api/api/admin_auth.py` +- [x] T027 [US2] Complete password sign-in in `apps/Backend/src/flycatch_api/services/auth_service.py` (generic 401 for wrong password / unknown email / inactive; both tokens on success; no refresh row on failure) +- [x] T028 [US2] Complete refresh rotation (new pair, old hash revoked, family revoke on reuse, idle/absolute expiry → 401) in `apps/Backend/src/flycatch_api/services/auth_service.py` +- [x] T029 [P] [US2] Generate Administration FE types/client from 002 contracts into `apps/Administration-FE/src/generated/` +- [x] T030 [US2] Rewrite the admin HTTP client as Bearer-only with refresh-once on 401 (no `credentials: 'include'`, no CSRF) in `apps/Administration-FE/src/lib/admin-api.ts` +- [x] T031 [US2] Update `SignInForm` to write tokens to memory and switch to the workspace in the same document (no `window.location`) in `apps/Administration-FE/src/components/SignInForm.tsx` +- [x] T032 [US2] Convert `AdminShell` to one-island auth state (signed-out form ↔ workspace; sign-out revokes then clears memory) in `apps/Administration-FE/src/components/AdminShell.tsx` +- [x] T033 [US2] Remove cookie-based redirects and separate sign-in navigation from `apps/Administration-FE/src/pages/admin/index.astro` and `apps/Administration-FE/src/pages/admin/sign-in.astro` +- [x] T034 [US2] Confirm the sign-in UI has no register / create-account / sign-up control in `apps/Administration-FE/src/components/SignInForm.tsx` +- [x] T035 [US2] Add Backend tests for token pair, generic failure, inactive-as-unknown, refresh rotation, and idle/absolute expiry in `apps/Backend/tests/integration/test_admin_auth.py` and `apps/Backend/tests/unit/test_session_policy.py` +- [x] T036 [US2] Add Playwright journey for sign-in success, generic failure, no sign-up, and sign-out in `apps/Administration-FE/tests/e2e/admin-auth.spec.ts` +- [x] T037 [US2] Extend axe-core WCAG 2.2 AA check for the sign-in state in `apps/Administration-FE/tests/e2e/a11y-admin.spec.ts` + +**Checkpoint**: Valid password reaches the workspace with in-memory tokens; failed attempts look identical and create 0 sessions; reload is treated as signed out + +--- + +## Phase 5: User Story 3 — Authorised staff member performs an allowed action (Priority: P2) + +**Goal**: A signed-in user whose roles include the required permission can view records, save drafts, and publish; the workspace shows only allowed controls; multi-role permissions are a union + +**Independent Test**: Sign in as the Administrator (or a user with view+draft+publish); open a placeholder record, save a draft, and publish. Each step succeeds. Public HTML stays on the previous snapshot until the documented rebuild (quickstart V4) + +### Implementation for User Story 3 + +- [x] T038 [US3] Enforce `records.view` on GET and `drafts.save` on PATCH in `apps/Backend/src/flycatch_api/api/admin_management.py` +- [x] T039 [US3] Enforce `records.publish` on `POST /admin/publish` in `apps/Backend/src/flycatch_api/api/publish.py` +- [x] T040 [US3] Return current role names and effective permissions on sign-in, refresh, and `GET /admin/auth/session` (loaded at request time, not from JWT claims) in `apps/Backend/src/flycatch_api/services/auth_service.py` +- [x] T041 [P] [US3] Show draft/publish controls from session permissions in `apps/Administration-FE/src/components/AdminShell.tsx`, `apps/Administration-FE/src/components/SiteSettingsEditor.tsx`, and `apps/Administration-FE/src/components/PageEditor.tsx` +- [x] T042 [US3] Add integration tests that an Administrator (and a multi-role user) can view, draft, and publish in `apps/Backend/tests/integration/test_rbac_grant.py` +- [x] T043 [US3] Update the Playwright draft→publish journey to use Bearer tokens in `apps/Administration-FE/tests/e2e/admin-draft-publish.spec.ts` + +**Checkpoint**: A user with publish can complete the existing draft/publish path; union of multiple roles is honoured + +--- + +## Phase 6: User Story 4 — Staff member is denied an action they are not permitted to perform (Priority: P2) + +**Goal**: Missing permissions are refused on the server (403 while still signed in). The workspace hides or disables the control. Unauthenticated callers get 401, never 403 + +**Independent Test**: Sign in as Editor (view+draft, no publish). Draft save works. Publish control is absent or clearly disabled. Direct `POST /admin/publish` returns 403 `permission_denied` for `records.publish` and does not change the public site. The same URL with no `Authorization` returns 401 (quickstart V5) + +### Implementation for User Story 4 + +- [x] T044 [US4] Return 403 `PermissionDenied` (`code`, `admin.action.forbidden`, required permission) when authenticated but missing the permission, and 401 (never 403) when unauthenticated or inactive, in `apps/Backend/src/flycatch_api/security/dependencies.py` +- [x] T045 [US4] Omit or `disabled` + `aria-disabled` the publish control when `records.publish` is absent; denial copy uses `admin.action.forbidden` in `apps/Administration-FE/src/components/AdminShell.tsx` and `apps/Administration-FE/src/components/PageEditor.tsx` +- [x] T046 [US4] Add integration tests: Editor draft succeeds; direct publish is 403 and site unchanged; session still valid; no-auth publish is 401 in `apps/Backend/tests/integration/test_rbac_deny.py` +- [x] T047 [US4] Add Playwright Editor deny journey (UI + accessible denial) in `apps/Administration-FE/tests/e2e/admin-rbac-deny.spec.ts` +- [x] T048 [US4] Extend axe-core WCAG 2.2 AA check for the permission-denied state in `apps/Administration-FE/tests/e2e/a11y-admin.spec.ts` + +**Checkpoint**: Editor cannot publish via UI or direct request; they remain signed in and can still draft; unauthenticated is 401 + +--- + +## Phase 7: Polish & Cross-Cutting Concerns + +**Purpose**: Contract parity, i18n/a11y/public non-regression, operator docs, and full quickstart validation + +- [x] T049 [P] Update Backend OpenAPI parity tests to assert served `/openapi.json` matches `specs/002-auth-rbac/contracts/` in `apps/Backend/tests/contract/test_openapi_parity.py` +- [x] T050 [P] Update Administration FE contract-drift check to reject hand-written token/permission DTOs in `apps/Administration-FE/scripts/check-contract-drift.mjs` +- [x] T051 [P] Scan Administration FE sign-in and denial UI for hard-coded user-facing strings in `apps/Administration-FE/scripts/check-i18n.mjs` (or existing Frontend i18n scan pattern) +- [x] T052 [P] Confirm public sitemap/robots still exclude `/admin` and `/api` and public JS budget remains 0 in `apps/Frontend/scripts/check-sitemap.mjs` and `apps/Frontend/scripts/check-performance-budget.mjs` +- [x] T053 Include `002-auth-rbac` contract validation and admin auth/RBAC tests in `.github/workflows/quality-gates.yml` +- [x] T054 Update staff-auth docs (JWT + Bearer, no cookie/CSRF, no sign-up) in `docs/conventions.md` and `README.md` +- [x] T055 Reject tokens, password hashes, and JWT secrets in client bundles and ordinary logs via `scripts/check-secrets.mjs` +- [x] T056 Run `specs/002-auth-rbac/quickstart.md` scenarios V1–V6 and record results in `docs/onboarding.md` + +**Checkpoint**: All FR-030 quality gates pass; public delivery is unchanged; contracts and consumers match + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — can start immediately +- **Foundational (Phase 2)**: Depends on Setup — **BLOCKS all user stories** +- **User Story 1 (Phase 3)**: Depends on Foundational (models, RBAC catalogue) +- **User Story 2 (Phase 4)**: Depends on Foundational + US1 (bootstrapped users to sign in) +- **User Story 3 (Phase 5)**: Depends on US2 (Bearer session + session permissions payload) +- **User Story 4 (Phase 6)**: Depends on US2; shares permission enforcement with US3 (can start after T038–T039) +- **Polish (Phase 7)**: Depends on all desired user stories + +### User Story Dependencies + +| Story | Priority | Depends on | Independent test | +| --- | --- | --- | --- | +| US1 | P1 | Phase 2 | Bootstrap two users + default roles; idempotent re-run; no sign-up | +| US2 | P1 | Phase 2, US1 | Password sign-in + generic failure + sign-out + refresh | +| US3 | P2 | US2 | Administrator view → draft → publish | +| US4 | P2 | US2 (US3 enforcement helpers) | Editor draft OK; publish denied in UI and direct POST | + +### Within Each User Story + +- Models and services before CLIs and HTTP routes +- Generated types before Administration FE client rewrite +- Token store before Bearer client and island auth state +- Core implementation before Playwright/axe verification +- Story complete before moving to the next priority when staffing is sequential + +### Parallel Opportunities + +- **Phase 1**: T002–T005 and T007 can run in parallel after T001 +- **Phase 2**: T009–T011 (models) and T015 + T020 (JWT + token-store) can run in parallel after T008 starts +- **Phase 3**: T024 (docs) can run in parallel with T021–T023 +- **Phase 4**: T029 (codegen) can run in parallel with T026–T028 (Backend auth) +- **Phase 5**: T038 and T039 (management vs publish enforcement) can run in parallel +- **Phase 6**: T046–T048 (Backend deny tests vs Playwright/axe) can run in parallel after T044–T045 +- **Phase 7**: T049–T052 can run in parallel + +--- + +## Parallel Example: User Story 1 + +```bash +# After Foundational models exist: +Task: "Implement bootstrap service in apps/Backend/src/flycatch_api/services/bootstrap_service.py" +Task: "Document bootstrap in docs/onboarding.md" + +# Then sequentially: +Task: "Implement flycatch-bootstrap CLI in apps/Backend/src/flycatch_api/cli/bootstrap.py" +Task: "Require --role on provision_admin.py" +Task: "Add integration tests in apps/Backend/tests/integration/test_bootstrap.py" +``` + +--- + +## Parallel Example: User Story 2 + +```bash +# Backend auth and FE codegen in parallel: +Task: "Implement admin-auth.v2 routes in apps/Backend/src/flycatch_api/api/admin_auth.py" +Task: "Generate types into apps/Administration-FE/src/generated/" + +# Then FE client + island (same token-store, sequential with each other): +Task: "Rewrite Bearer client in apps/Administration-FE/src/lib/admin-api.ts" +Task: "Update SignInForm.tsx and AdminShell.tsx for in-memory session" +``` + +--- + +## Parallel Example: User Story 3 + +```bash +# Route enforcement in parallel (different files): +Task: "Enforce view/draft in apps/Backend/src/flycatch_api/api/admin_management.py" +Task: "Enforce publish in apps/Backend/src/flycatch_api/api/publish.py" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup +2. Complete Phase 2: Foundational (CRITICAL — blocks all stories) +3. Complete Phase 3: User Story 1 +4. **STOP and VALIDATE**: Run quickstart V1 — two users, two roles, idempotent bootstrap, no secrets leaked +5. Demo operator bootstrap before building the JWT workspace + +### Incremental Delivery + +1. Setup + Foundational → JWT/RBAC machinery ready +2. US1 → Bootstrap defaults (**MVP**) +3. US2 → Password sign-in + refresh + in-memory Bearer session +4. US3 → Authorised view/draft/publish +5. US4 → Server-enforced deny + accessible UI denial +6. Polish → Contract parity, i18n/a11y, public non-regression, full V1–V6 + +### Parallel Team Strategy + +With multiple developers after Phase 2: + +- **Developer A**: US1 (bootstrap CLI + provision `--role`) +- **Developer B**: US2 Backend auth routes + AuthService (after US1 users exist, or using provision) +- **Developer C**: US2 Administration FE token-store / island (after T020 and codegen) + +US3 and US4 should wait for US2 Bearer session. After T038–T039, grant and deny verification can proceed in parallel. + +--- + +## Notes + +- OpenAPI files in `specs/002-auth-rbac/contracts/` already exist — do not rewrite them; implement and consume them +- Foundation payload schemas (`content.v1`, `site-settings.v1`, `seo-metadata.v1`) stay in `specs/001-website-foundation/contracts/` +- `[P]` tasks = different files, no dependencies on incomplete tasks in the same batch +- `[Story]` label maps task to a specific user story for traceability +- Access JWT MUST NOT contain roles or permissions; enforcement is request-time +- Tokens live in Administration FE memory only; a full document navigation after sign-in is a defect +- `apps/Frontend` is out of change scope except non-regression gates +- No Administration UI for user or role management in this feature +- Commit using Conventional Commits (constitution IV) +- Stop at any checkpoint to validate the story independently before proceeding From 1e3f20b5cc8f082c0fc922f0b8dae40d7beb3f39 Mon Sep 17 00:00:00 2001 From: athulrajtflycatchtech Date: Mon, 17 Aug 2026 10:41:06 +0530 Subject: [PATCH 3/3] feat(admin-panel): enhance Administration FE with JWT authentication, RBAC integration, and improved contract checks; update README and deployment instructions --- .github/workflows/quality-gates.yml | 20 +- README.md | 11 +- apps/Administration-FE/Dockerfile | 4 +- apps/Administration-FE/astro.config.mjs | 1 + apps/Administration-FE/package.json | 1 + .../scripts/check-contract-drift.mjs | 44 ++++- .../scripts/generate-client.mjs | 31 +++- .../src/components/AdminShell.tsx | 126 ++++++++++--- .../src/components/PageEditor.tsx | 41 ++++- .../src/components/SignInForm.tsx | 21 ++- .../src/components/SiteSettingsEditor.tsx | 41 ++++- apps/Administration-FE/src/i18n/en.json | 3 + apps/Administration-FE/src/lib/admin-api.ts | 135 +++++++++++--- apps/Administration-FE/src/lib/token-store.ts | 43 ++++- .../src/pages/admin/sign-in.astro | 6 +- .../tests/e2e/a11y-admin.spec.ts | 26 ++- .../tests/e2e/admin-auth.spec.ts | 3 + .../tests/e2e/admin-draft-publish.spec.ts | 20 +- .../tests/unit/token-store.test.ts | 39 ++++ apps/Backend/alembic/env.py | 9 +- apps/Backend/pyproject.toml | 2 + .../src/flycatch_api/api/admin_auth.py | 84 ++++++--- .../src/flycatch_api/api/admin_management.py | 20 +- apps/Backend/src/flycatch_api/api/publish.py | 5 +- .../src/flycatch_api/cli/provision_admin.py | 13 +- .../src/flycatch_api/cli/seed_records.py | 30 ++- apps/Backend/src/flycatch_api/config.py | 2 + apps/Backend/src/flycatch_api/main.py | 24 ++- .../src/flycatch_api/models/__init__.py | 14 +- .../src/flycatch_api/models/admin_session.py | 3 +- .../src/flycatch_api/models/administrator.py | 4 + .../src/flycatch_api/schemas/__init__.py | 70 ++++--- .../Backend/src/flycatch_api/security/csrf.py | 31 +--- .../src/flycatch_api/security/dependencies.py | 97 ++++++---- .../src/flycatch_api/security/session.py | 12 +- .../src/flycatch_api/services/auth_service.py | 157 +++++++++++++--- apps/Backend/tests/conftest.py | 173 +++++++++++++++++- .../tests/contract/test_openapi_parity.py | 39 +++- .../Backend/tests/unit/test_session_policy.py | 26 ++- deployment/.env.example | 2 + deployment/Caddyfile | 9 + deployment/docker-compose.yml | 4 + docs/conventions.md | 3 + docs/onboarding.md | 49 +++-- scripts/check-secrets.mjs | 10 +- scripts/validate-contracts.mjs | 30 +-- 46 files changed, 1221 insertions(+), 317 deletions(-) create mode 100644 apps/Administration-FE/tests/unit/token-store.test.ts diff --git a/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml index 2612f11..d5917b0 100644 --- a/.github/workflows/quality-gates.yml +++ b/.github/workflows/quality-gates.yml @@ -2,7 +2,7 @@ name: Quality Gates on: push: - branches: [main, '001-website-foundation'] + branches: [main, '001-website-foundation', '002-auth-rbac'] pull_request: jobs: @@ -46,3 +46,21 @@ jobs: working-directory: apps/Frontend - run: pnpm run check:all working-directory: apps/Frontend + + administration-fe: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: apps/Administration-FE/package-lock.json + - run: npm ci + working-directory: apps/Administration-FE + - run: npm run generate:client + working-directory: apps/Administration-FE + - run: npm run check:contracts + working-directory: apps/Administration-FE + - run: npm run check:i18n + working-directory: apps/Administration-FE diff --git a/README.md b/README.md index e1c4390..bba3cd6 100644 --- a/README.md +++ b/README.md @@ -27,18 +27,19 @@ apps/ └── Backend/ deployment/ # Docker Compose, environment config, gateway specs/001-website-foundation/ # Feature spec, plan, contracts, quickstart +specs/002-auth-rbac/ # JWT auth + RBAC spec, plan, contracts, quickstart docs/ # Conventions and onboarding (implementation phase) ``` ## OpenAPI — single source of truth -All cross-boundary shapes live in `specs/001-website-foundation/contracts/` (OpenAPI 3.1). +Foundation payload schemas live in `specs/001-website-foundation/contracts/` (OpenAPI 3.1). Staff auth, RBAC, management, and publish live in `specs/002-auth-rbac/contracts/`. -- **Backend** MUST implement these contracts. +- **Backend** MUST implement these contracts. Staff auth is JWT access + refresh (`Authorization: Bearer`), not cookies or CSRF. - **Frontend** MUST generate or validate build-time types from the content, settings, SEO, and publish schemas. -- **Administration FE** MUST use an OpenAPI-generated API client — no hand-written DTOs that bypass the contract. +- **Administration FE** MUST generate types from `admin-auth.v2`, `admin-rbac.v1`, `admin-management.v2`, and `publish.v2` — no hand-written token or permission DTOs. Tokens stay in memory only. -See [contracts/README.md](specs/001-website-foundation/contracts/README.md). +See [001 contracts](specs/001-website-foundation/contracts/README.md) and [002 contracts](specs/002-auth-rbac/contracts/README.md). ## Deployment @@ -68,7 +69,7 @@ Gateway (default `http://localhost:8080`): After services are healthy: -1. Run Backend migrations and provision an administrator. +1. Run Backend migrations and `flycatch-bootstrap` (two staff users + default roles). `--role` is required on later `flycatch-provision-admin` calls. 2. Export the published snapshot and build `apps/Frontend`. 3. Rebuild containers when app images change: `docker compose -f deployment/docker-compose.yml up -d --build` diff --git a/apps/Administration-FE/Dockerfile b/apps/Administration-FE/Dockerfile index c1d5132..2c20111 100644 --- a/apps/Administration-FE/Dockerfile +++ b/apps/Administration-FE/Dockerfile @@ -3,8 +3,10 @@ WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . +COPY --from=specs . /specs +ENV CONTRACTS_DIR=/specs/002-auth-rbac/contracts ENV PUBLIC_ORIGIN=http://localhost:8080 -RUN npm run build +RUN npm run generate:client && npm run build FROM node:22-alpine WORKDIR /app diff --git a/apps/Administration-FE/astro.config.mjs b/apps/Administration-FE/astro.config.mjs index bf51d4b..48c973b 100644 --- a/apps/Administration-FE/astro.config.mjs +++ b/apps/Administration-FE/astro.config.mjs @@ -4,6 +4,7 @@ import react from '@astrojs/react'; export default defineConfig({ output: 'static', base: '/admin', + trailingSlash: 'always', integrations: [react()], vite: { server: { diff --git a/apps/Administration-FE/package.json b/apps/Administration-FE/package.json index adeb50c..e25703f 100644 --- a/apps/Administration-FE/package.json +++ b/apps/Administration-FE/package.json @@ -10,6 +10,7 @@ "check": "astro check", "generate:client": "node scripts/generate-client.mjs", "check:contracts": "node scripts/check-contract-drift.mjs", + "check:i18n": "node scripts/check-i18n.mjs", "test:unit": "vitest run", "test:e2e": "playwright test", "lint": "eslint src --ext .ts,.tsx,.astro" diff --git a/apps/Administration-FE/scripts/check-contract-drift.mjs b/apps/Administration-FE/scripts/check-contract-drift.mjs index 1d2bc89..1fcf934 100644 --- a/apps/Administration-FE/scripts/check-contract-drift.mjs +++ b/apps/Administration-FE/scripts/check-contract-drift.mjs @@ -1,2 +1,44 @@ #!/usr/bin/env node -console.log('Contract drift check: admin-api.ts endpoints aligned to admin-auth, admin-management, publish contracts'); +import { readFileSync, existsSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const generatedDir = join(root, 'src/generated'); +const required = [ + 'admin-auth.v2.ts', + 'admin-rbac.v1.ts', + 'admin-management.v2.ts', + 'publish.v2.ts', +]; + +let failed = false; +for (const file of required) { + const full = join(generatedDir, file); + if (!existsSync(full)) { + console.error(`Missing generated contract file: ${file}. Run npm run generate:client`); + failed = true; + } +} + +const api = readFileSync(join(root, 'src/lib/admin-api.ts'), 'utf8'); +const forbidden = [ + /export (type|interface) TokenPair\s*\{/, + /export (type|interface) SessionContext\s*\{/, + /export (type|interface) PermissionName\s*=\s*['"]/, + /export (type|interface) PermissionDenied\s*\{/, +]; +for (const pattern of forbidden) { + if (pattern.test(api)) { + console.error(`Hand-written token/permission DTO detected in admin-api.ts: ${pattern}`); + failed = true; + } +} + +if (!api.includes('../generated/admin-auth.v2') || !api.includes('../generated/admin-rbac.v1')) { + console.error('admin-api.ts must import token/permission types from generated 002 contracts'); + failed = true; +} + +if (failed) process.exit(1); +console.log('Administration FE contract drift check passed'); diff --git a/apps/Administration-FE/scripts/generate-client.mjs b/apps/Administration-FE/scripts/generate-client.mjs index d6d227d..438ad66 100644 --- a/apps/Administration-FE/scripts/generate-client.mjs +++ b/apps/Administration-FE/scripts/generate-client.mjs @@ -1,2 +1,31 @@ #!/usr/bin/env node -console.log('OpenAPI client generation: fetch client in src/lib/admin-api.ts matches contract paths'); +import { mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(here, '../../..'); +const outDir = join(here, '../src/generated'); +const contractsDir = + process.env.CONTRACTS_DIR || join(repoRoot, 'specs/002-auth-rbac/contracts'); + +const contracts = [ + 'admin-auth.v2.yaml', + 'admin-rbac.v1.yaml', + 'admin-management.v2.yaml', + 'publish.v2.yaml', +]; + +mkdirSync(outDir, { recursive: true }); + +for (const file of contracts) { + const src = join(contractsDir, file); + const dest = join(outDir, file.replace(/\.yaml$/, '.ts')); + execFileSync('npx', ['openapi-typescript', src, '-o', dest], { + stdio: 'inherit', + cwd: join(here, '..'), + }); +} + +console.log('Generated Administration FE types from specs/002-auth-rbac/contracts/'); diff --git a/apps/Administration-FE/src/components/AdminShell.tsx b/apps/Administration-FE/src/components/AdminShell.tsx index f0e6ebf..4ecb036 100644 --- a/apps/Administration-FE/src/components/AdminShell.tsx +++ b/apps/Administration-FE/src/components/AdminShell.tsx @@ -1,47 +1,62 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { - getCsrfToken, getPageRecord, getSession, getSiteSettingsRecord, + hasPermission, publishRecord, savePageDraft, saveSiteSettingsDraft, signOut, + type SessionContext, } from '../lib/admin-api'; +import { hasTokens } from '../lib/token-store'; import { t } from '../lib/i18n'; import PageEditor from './PageEditor'; +import SignInForm from './SignInForm'; import SiteSettingsEditor from './SiteSettingsEditor'; type View = 'site_settings' | 'home'; export default function AdminShell() { const [view, setView] = useState('site_settings'); - const [sessionEmail, setSessionEmail] = useState(null); - const [csrf, setCsrf] = useState(''); + const [session, setSession] = useState(null); const [siteSettings, setSiteSettings] = useState | null>(null); const [homePage, setHomePage] = useState | null>(null); const [message, setMessage] = useState(null); const [error, setError] = useState(null); + const [workspaceError, setWorkspaceError] = useState(null); + const [ready, setReady] = useState(!hasTokens()); - useEffect(() => { - async function load() { - try { - const session = await getSession(); - setSessionEmail(session.email); - const token = await getCsrfToken(); - setCsrf(token); - const settings = await getSiteSettingsRecord(); - setSiteSettings(settings as Record); - const page = await getPageRecord('home'); - setHomePage(page as Record); - } catch { - window.location.href = '/admin/sign-in'; - } + const loadWorkspace = useCallback(async () => { + setWorkspaceError(null); + const nextSession = await getSession(); + setSession(nextSession); + try { + const settings = await getSiteSettingsRecord(); + setSiteSettings(settings as Record); + const page = await getPageRecord('home'); + setHomePage(page as Record); + } catch { + setSiteSettings(null); + setHomePage(null); + setWorkspaceError(t('admin.workspace.load_failed')); + } finally { + setReady(true); } - load(); }, []); + useEffect(() => { + if (!hasTokens()) { + setReady(true); + return; + } + loadWorkspace().catch(() => { + setSession(null); + setReady(true); + }); + }, [loadWorkspace]); + async function refreshData() { const settings = await getSiteSettingsRecord(); setSiteSettings(settings as Record); @@ -49,21 +64,62 @@ export default function AdminShell() { setHomePage(page as Record); } + async function handleSignedIn() { + setError(null); + setWorkspaceError(null); + try { + await loadWorkspace(); + } catch { + // loadWorkspace sets workspaceError when records are missing + } + } + async function handleSignOut() { await signOut(); - window.location.href = '/admin/sign-in'; + setSession(null); + setSiteSettings(null); + setHomePage(null); + setMessage(null); + setError(null); + setWorkspaceError(null); + } + + if (!ready) { + return ( +
+

{t('admin.workspace.title')}

+
+ ); } - if (!sessionEmail || !siteSettings || !homePage) { - return

{t('admin.workspace.title')}

; + if (!session) { + return ( +
+ +
+ ); } + if (!siteSettings || !homePage) { + return ( +
+

{workspaceError || t('admin.workspace.load_failed')}

+ +
+ ); + } + + const canDraft = hasPermission(session, 'drafts.save'); + const canPublish = hasPermission(session, 'records.publish'); + return (

{t('admin.workspace.title')}

-

{sessionEmail}

+

{session.email}

@@ -104,13 +160,19 @@ export default function AdminShell() { {view === 'site_settings' && ( { - await saveSiteSettingsDraft(draft, csrf); - setMessage('Draft saved'); + await saveSiteSettingsDraft(draft); + setMessage(t('admin.draft.saved')); await refreshData(); }} onPublish={async () => { - await publishRecord('site_settings', 'default', csrf); + if (!canPublish) { + setError(t('admin.action.forbidden')); + return; + } + await publishRecord('site_settings', 'default'); setMessage(t('admin.publish.success')); await refreshData(); }} @@ -119,13 +181,19 @@ export default function AdminShell() { {view === 'home' && ( { - await savePageDraft('home', draft, csrf); - setMessage('Draft saved'); + await savePageDraft('home', draft); + setMessage(t('admin.draft.saved')); await refreshData(); }} onPublish={async () => { - await publishRecord('page', 'home', csrf); + if (!canPublish) { + setError(t('admin.action.forbidden')); + return; + } + await publishRecord('page', 'home'); setMessage(t('admin.publish.success')); await refreshData(); }} diff --git a/apps/Administration-FE/src/components/PageEditor.tsx b/apps/Administration-FE/src/components/PageEditor.tsx index 2ec1345..cb3f365 100644 --- a/apps/Administration-FE/src/components/PageEditor.tsx +++ b/apps/Administration-FE/src/components/PageEditor.tsx @@ -3,11 +3,19 @@ import { t } from '../lib/i18n'; interface Props { record: Record; + canDraft: boolean; + canPublish: boolean; onSaveDraft: (draft: Record) => Promise; onPublish: () => Promise; } -export default function PageEditor({ record, onSaveDraft, onPublish }: Props) { +export default function PageEditor({ + record, + canDraft, + canPublish, + onSaveDraft, + onPublish, +}: Props) { const draft = (record.draft || {}) as Record; const seo = (draft.seo || {}) as Record; const [title, setTitle] = useState(String(seo.title || '')); @@ -57,13 +65,32 @@ export default function PageEditor({ record, onSaveDraft, onPublish }: Props) {