From a083ae94a4d4daa1b455fcd0fbce4240dfe74d79 Mon Sep 17 00:00:00 2001 From: Patrick Plate Date: Wed, 24 Jun 2026 14:12:19 +0200 Subject: [PATCH] plan: add Architecture.md (tier model, flows, data model, packaging) --- Architecture.md | 525 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 525 insertions(+) create mode 100644 Architecture.md diff --git a/Architecture.md b/Architecture.md new file mode 100644 index 0000000..3090c32 --- /dev/null +++ b/Architecture.md @@ -0,0 +1,525 @@ +# Architecture + +**Status:** Draft v1 +**Date:** 2026-06-24 +**Owner:** Patrick (plate-software) + +--- + +## 1. Two artifacts, one contract + +plate-auth ships as **two coordinated artifacts** that share a single wire contract (the HMAC-signed +exchange envelope + the JWT shape). + +```mermaid +flowchart LR + subgraph Backend["plate-auth-starter (Maven)"] + BE[de.platesoft.auth.*
Spring Boot 4 auto-config] + end + subgraph Frontend["@platesoft/auth (npm)"] + FE[NextAuth v5 wiring +
exchange + proxy helpers] + end + Wire[(Wire contract:
POST /api/auth/exchange
HMAC-SHA256 envelope
JWT bearer)] + FE -.signs.-> Wire + BE -.verifies.-> Wire +``` + +The wire contract is the **only thing the two artifacts know about each other**. Either side can be +re-implemented in another language as long as the contract is honored. + +--- + +## 2. Tier model — what is in, what is out + +We split InspectFlow's auth/membership system into three tiers. plate-auth ships **T1 + T2 only**. + +```mermaid +flowchart TB + subgraph T1["T1 — Auth Core (plate-auth-starter)"] + T1a[User + UserIdentity entities] + T1b[JwtAuthenticationFilter] + T1c[JwtService] + T1d[SecurityConfig auto-config] + T1e[OAuthController + ExchangeService] + T1f[AuthController login/register/refresh/me] + T1g[LoginEventService + revinfo actor] + end + subgraph T2["T2 — Multi-Tenancy (plate-auth-starter)"] + T2a[Membership entity + service] + T2b[Invitation entity + service + controller] + T2c[AccessRequest entity + service + controller] + T2d[OrgContextResolver] + T2e[AdminAuditController] + end + subgraph T3["T3 — Stays in consuming app"] + T3a[Company / Workspace / Club
concrete org entity] + T3b[OnboardingService
business-state machine] + T3c[TenantAutoMapService
MS-tenant → company mapping] + T3d[Org-specific embeddings,
industry codes, etc.] + end + + T2 -.references via FK.-> T3 + T2 -.OrgContextResolver hook.-> T3 +``` + +**Why this cut:** + +- **T1** has zero domain coupling — works for any product. +- **T2** is "user × org × role" — also generic, but its FK target (`org_id`) is per-product. +- **T3** is product-specific business logic. It would constrain consumers if we extracted it. + +The seam between T2 and T3 is an **`OrgValidator` SPI** (Service Provider Interface) that the consuming app +implements: + +```java +public interface OrgValidator { + boolean exists(OrgType type, UUID orgId); + String displayName(OrgType type, UUID orgId); +} +``` + +plate-auth's `MembershipService.grant(…)` calls `orgValidator.exists(…)` instead of joining to a `companies` +table directly. This is the only generic-↔-domain coupling point. + +--- + +## 3. Public API surface (Spring Boot starter) + +What a consumer can call / configure / override. Everything else is `package-private` or `@Internal`. + +### 3.1 HTTP endpoints (mounted under `/api/auth/**` and `/api/`) + +| Endpoint | Method | Public? | Purpose | +|---|---|---|---| +| `/api/auth/exchange` | POST | Public (HMAC-signed) | NextAuth → backend session bootstrap | +| `/api/auth/register` | POST | Public (if `plate.auth.registration.enabled=true`) | Password registration | +| `/api/auth/login` | POST | Public | Password login | +| `/api/auth/refresh` | POST | Public | Refresh token rotation | +| `/api/auth/logout` | POST | Public | Refresh token revocation | +| `/api/auth/me` | GET | Authenticated | Current user + memberships | +| `/api/auth/config` | GET | Public | Provider list + registration flag | +| `/api/invitations` | CRUD | Authenticated (admin) | Invite management | +| `/api/invitations/accept` | POST | Authenticated | Accept invite | +| `/api/access-requests` | CRUD | Authenticated (admin) | Access request management | +| `/api/admin/login-events` | GET | `hasRole('ADMIN')` | Audit log | +| `/api/admin/audit/revisions` | GET | `hasRole('ADMIN')` | Envers revision browser | + +### 3.2 Spring beans the consumer can inject + +| Bean | Purpose | +|---|---| +| [`JwtService`](Architecture.md:62) | Generate / parse JWTs. Issue tokens for arbitrary `User`. | +| `OAuthService` | Find-or-create User+Identity from exchange payload. | +| `MembershipService` | Grant / revoke / list memberships. | +| `InvitationService` | Create / accept / revoke invitations. | +| `AccessRequestService` | Submit / approve / deny access requests. | +| `LoginEventService` | Record login attempts (async). | + +### 3.3 Configuration properties (`plate.auth.*`) + +```yaml +plate: + auth: + jwt: + secret: ${PLATE_AUTH_JWT_SECRET} # ≥32 chars, HMAC-SHA256 key + access-expiration: PT15M # ISO-8601 duration + refresh-expiration: P30D + issuer: plate-auth + exchange: + secret: ${PLATE_AUTH_EXCHANGE_SECRET} # ≥32 chars, shared with frontend + max-age: PT60S # envelope freshness window + nonce-ttl: PT5M # replay-protection window + registration: + enabled: false # password registration on/off + cors: + allowed-origins: # space-separated or list + - https://app.example.com + providers: + google: + enabled: true + microsoft: + enabled: false # Entra ID — opt-in + email-magic-link: + enabled: false # NextAuth Email provider +``` + +### 3.4 Extension points (SPI) + +Consumers implement these to plug their domain into plate-auth: + +| Interface | Required? | Default | Purpose | +|---|---|---|---| +| `OrgValidator` | **Yes** (if T2 used) | — | Validate `org_id` exists in consumer's `companies` / `workspaces` / etc. | +| `OrgDisplayNameResolver` | Optional | Returns `org_id.toString()` | Pretty-print org for invitations / emails. | +| `InvitationMailer` | Optional | No-op logger | Send invitation emails. Default just logs the token URL. | +| `AccessRequestMailer` | Optional | No-op logger | Notify admins of new access requests. | +| `OnboardingHook` | Optional | No-op | Called on first successful sign-in — consumer's place to wire T3 onboarding. | + +All extension points are `@ConditionalOnMissingBean` — register your own bean to override. + +--- + +## 4. Public API surface (npm package) + +### 4.1 Exports + +```ts +// @platesoft/auth/config +export function createAuthConfig(opts: PlateAuthConfigOptions): NextAuthConfig; + +// @platesoft/auth/exchange +export async function exchangeWithBackend(envelope: ExchangeEnvelope): Promise; +export function signEnvelope(envelope: object, secret: string): { signature: string; envelope: string }; + +// @platesoft/auth/proxy +export function createProxyHandlers(opts: ProxyOptions): { + GET: RouteHandler; POST: RouteHandler; PUT: RouteHandler; PATCH: RouteHandler; DELETE: RouteHandler; +}; + +// @platesoft/auth/middleware +export function createAuthMiddleware(opts?: MiddlewareOptions): NextMiddleware; + +// @platesoft/auth/client +export { useSession, signIn, signOut } from 'next-auth/react'; // re-export +export function useAccessToken(): string | null; +export function useMemberships(): Membership[]; +``` + +### 4.2 Required env vars + +```bash +# Frontend (Next.js) +NEXTAUTH_SECRET=... # NextAuth session secret +NEXTAUTH_EXCHANGE_SECRET=... # MUST match backend plate.auth.exchange.secret +NEXT_PUBLIC_BACKEND_URL=https://api.example.com +GOOGLE_CLIENT_ID=... +GOOGLE_CLIENT_SECRET=... +# Optional +MICROSOFT_ENTRA_CLIENT_ID=... +MICROSOFT_ENTRA_CLIENT_SECRET=... +MICROSOFT_ENTRA_TENANT_ID=common +EMAIL_SERVER=... # only if magic-link enabled +EMAIL_FROM=... +``` + +--- + +## 5. End-to-end sign-in flow + +```mermaid +sequenceDiagram + autonumber + actor U as User + participant FE as Browser (Next.js) + participant NA as NextAuth (/api/auth/[...nextauth]) + participant G as Google + participant BE as Spring Boot (plate-auth) + participant DB as Postgres + + U->>FE: click "Sign in with Google" + FE->>NA: signIn('google') + NA->>G: redirect to Google OAuth + G-->>NA: callback with id_token + NA->>NA: signIn callback fires + NA->>NA: build ExchangeEnvelope
{provider, providerSubject, email, name, nonce, iat} + NA->>NA: sign envelope with HMAC-SHA256(NEXTAUTH_EXCHANGE_SECRET) + NA->>BE: POST /api/auth/exchange
X-Exchange-Signature:
body: envelope + BE->>BE: ExchangeService.verifyAndParse()
(HMAC verify, max-age 60s, nonce dedup) + BE->>DB: SELECT user_identity WHERE provider+subject + alt Identity exists + DB-->>BE: User row + else New identity + BE->>DB: INSERT user + user_identity + BE->>BE: OnboardingHook.onFirstSignIn(user) + end + BE->>DB: INSERT login_event (success) + BE-->>NA: 200 {access_token, refresh_token, user, memberships} + NA->>NA: store tokens in NextAuth JWT + NA-->>FE: set session cookie + FE-->>U: redirect to app +``` + +Key invariants: + +- **Step 5 (signIn callback)** is where NextAuth's "I authenticated this user" claim becomes plate-auth's + "I issue tokens for this user" — bridged by the HMAC envelope. +- **Step 7 (HMAC verify)** is the only thing preventing a malicious caller from minting tokens for any + Google account. +- **Step 8 (nonce dedup)** is the only thing preventing replay of a captured envelope within its 60s + freshness window. +- **Step 13 (`OnboardingHook`)** is the T3 escape hatch — consumers wire whatever first-login flow they need. + +--- + +## 6. Request → API flow (authenticated) + +```mermaid +sequenceDiagram + autonumber + actor U as User + participant FE as Browser + participant PR as Next.js /api/[...path] proxy + participant NA as NextAuth auth() + participant BE as Spring Boot + participant JF as JwtAuthFilter + participant OR as OrgContextResolver + participant CT as @RestController + + U->>FE: action triggers fetch /api/things + FE->>PR: GET /api/things (no auth header — session cookie only) + PR->>NA: auth() — read session + NA-->>PR: {accessToken, user} + PR->>BE: GET /api/things
Authorization: Bearer
X-Org-Id: (optional) + BE->>JF: JwtAuthFilter.doFilter() + JF->>JF: extract + verify JWT (HMAC SHA-256, issuer check) + JF->>JF: set SecurityContext (principal = email, authorities = [ROLE_]) + JF->>OR: OrgContextResolver.resolve() + OR->>OR: parse X-Org-Id, validate membership via MembershipService + OR->>OR: set OrgContext threadlocal + OR->>CT: forward to controller + CT-->>BE: response + BE-->>PR: 200 response (or 401/403) + PR-->>FE: passthrough response +``` + +The proxy pattern (step 2-6) is the key design choice: **the browser never sees the JWT directly**. The +JWT lives in the encrypted NextAuth session cookie; the proxy adds the `Authorization` header server-side +before forwarding. This sidesteps XSS token theft. + +--- + +## 7. Data model + +```mermaid +erDiagram + USERS ||--o{ USER_IDENTITIES : "1:N" + USERS ||--o{ MEMBERSHIPS : "1:N" + USERS ||--o{ INVITATIONS : "created_by" + USERS ||--o{ ACCESS_REQUESTS : "requested_by" + USERS ||--o{ LOGIN_EVENTS : "actor" + MEMBERSHIPS }o..|| ORG_TABLE : "(org_type, org_id)
polymorphic FK" + INVITATIONS }o..|| ORG_TABLE : "polymorphic" + ACCESS_REQUESTS }o..|| ORG_TABLE : "polymorphic" + + USERS { + uuid id PK + text email UK + text password_hash "nullable for OAuth-only" + text first_name + text last_name + text role "ROLE_USER | ROLE_ADMIN" + boolean active + uuid default_org_id "nullable" + text last_provider "google|microsoft|email|password" + bigint version "optimistic lock" + } + USER_IDENTITIES { + uuid id PK + uuid user_id FK + text provider "google|microsoft|email|password" + text subject "provider's stable user id" + text email + text tenant_id "MS Entra tenant, nullable" + timestamptz linked_at + timestamptz last_login_at + } + MEMBERSHIPS { + uuid id PK + uuid user_id FK + text org_type "COMPANY|WORKSPACE|CLUB|..." + uuid org_id "polymorphic — validated via OrgValidator SPI" + text role "OWNER|ADMIN|MEMBER|VIEWER" + text status "ACTIVE|SUSPENDED|REVOKED" + uuid granted_by FK + text grant_reason + timestamptz revoked_at + uuid revoked_by FK + } + INVITATIONS { + uuid id PK + text token UK "64-char single-use" + text email + text org_type + uuid org_id + text role + text status "PENDING|ACCEPTED|REVOKED|EXPIRED" + uuid created_by FK + timestamptz created_at + timestamptz expires_at + timestamptz accepted_at + } + ACCESS_REQUESTS { + uuid id PK + uuid requester_id FK + text org_type + uuid org_id + text requested_role "default VIEWER" + text justification + text status "PENDING|APPROVED|DENIED|EXPIRED" + uuid reviewer_id FK + text decision_reason + } + LOGIN_EVENTS { + bigint id PK + uuid user_id FK + text email "denormalized for failed attempts" + text provider + text outcome "SUCCESS|FAILURE|LOCKED" + text ip_address + text user_agent + text correlation_id + timestamptz occurred_at + } +``` + +**The polymorphic FK on `memberships.org_id`** is the only "loose" join in the schema. It is intentional: +plate-auth must not require a specific concrete org table. Validation is enforced via: + +1. The `OrgValidator` SPI (application-level check at membership-grant time). +2. The `fn_membership_org_fk()` trigger (consumer-defined — InspectFlow's trigger validates against + `companies`; Sparkboard's will validate against `workspaces`). + +--- + +## 8. Package layout + +### 8.1 Maven module + +``` +plate-auth/ +├── plate-auth-starter/ # the published artifact +│ ├── pom.xml # de.platesoft:plate-auth-starter:0.1.0 +│ └── src/main/java/de/platesoft/auth/ +│ ├── PlateAuthAutoConfiguration.java +│ ├── PlateAuthProperties.java +│ ├── config/ +│ │ └── SecurityConfig.java +│ ├── filter/ +│ │ ├── JwtAuthenticationFilter.java +│ │ └── OrgContextResolver.java +│ ├── service/ +│ │ ├── JwtService.java +│ │ ├── OAuthService.java +│ │ ├── MembershipService.java +│ │ ├── InvitationService.java +│ │ ├── AccessRequestService.java +│ │ ├── LoginEventService.java +│ │ └── ExchangeService.java +│ ├── controller/ +│ │ ├── AuthController.java +│ │ ├── OAuthController.java +│ │ ├── InvitationController.java +│ │ ├── AccessRequestController.java +│ │ └── AdminAuditController.java +│ ├── entity/ +│ │ ├── User.java +│ │ ├── UserIdentity.java +│ │ ├── Membership.java +│ │ ├── Invitation.java +│ │ ├── AccessRequest.java +│ │ └── LoginEvent.java +│ ├── repository/... +│ ├── dto/ +│ │ ├── request/... +│ │ └── response/... +│ ├── spi/ +│ │ ├── OrgValidator.java +│ │ ├── OrgDisplayNameResolver.java +│ │ ├── InvitationMailer.java +│ │ ├── AccessRequestMailer.java +│ │ └── OnboardingHook.java +│ └── enums/ +│ ├── OrgType.java +│ ├── MembershipRole.java +│ ├── MembershipStatus.java +│ ├── InvitationStatus.java +│ ├── AccessRequestStatus.java +│ ├── LoginProvider.java +│ └── Role.java +│ └── resources/ +│ ├── META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +│ └── db/migration/auth/ +│ ├── V1__create_users_and_identities.sql +│ ├── V2__create_memberships.sql +│ ├── V3__create_invitations.sql +│ ├── V4__create_access_requests.sql +│ ├── V5__add_microsoft_tenant_id_index.sql +│ └── V6__create_login_events_and_revinfo_actor.sql +└── plate-auth-tests/ # internal integration tests, not published +``` + +### 8.2 npm package + +``` +@platesoft/auth/ +├── package.json # @platesoft/auth@0.1.0 +├── src/ +│ ├── config/ +│ │ ├── index.ts # createAuthConfig() +│ │ ├── providers/ +│ │ │ ├── google.ts +│ │ │ ├── microsoft.ts +│ │ │ └── email.ts +│ │ └── callbacks/ +│ │ ├── signIn.ts # exchange-with-backend signIn +│ │ └── jwt.ts +│ ├── exchange/ +│ │ ├── envelope.ts # signEnvelope, verifyEnvelope (Edge-compatible HMAC) +│ │ └── client.ts # exchangeWithBackend +│ ├── proxy/ +│ │ └── handlers.ts # createProxyHandlers +│ ├── middleware/ +│ │ └── index.ts # createAuthMiddleware +│ └── client/ +│ ├── hooks.ts # useAccessToken, useMemberships +│ └── index.ts # re-exports from next-auth/react +└── tests/ +``` + +--- + +## 9. Distribution + +```mermaid +flowchart LR + Source[plate-auth git repo
git.plate-software.de/pplate/plate-auth] --> CI{Gitea Actions} + CI -->|tag v0.x.y| MavenReg[Gitea Package Registry
Maven] + CI -->|tag v0.x.y| NpmReg[Gitea Package Registry
npm] + MavenReg --> IF1[InspectFlow backend] + MavenReg --> SB1[Sparkboard backend] + NpmReg --> IF2[InspectFlow frontend] + NpmReg --> SB2[Sparkboard frontend] +``` + +- Single git repo, two artifact pipelines. +- Tag `v0.1.0` triggers publish of both `de.platesoft:plate-auth-starter:0.1.0` and `@platesoft/auth@0.1.0`. + **Versions stay locked in lockstep** — frontend 0.2.0 implies backend 0.2.0 is the required peer. +- Gitea Package Registry is the only distribution channel. No public registry. + +--- + +## 10. Threat model summary + +| Threat | Mitigation | +|---|---| +| Forged exchange envelope | HMAC-SHA256 with ≥32-char shared secret, constant-time compare | +| Replay of captured envelope | 60s `max-age` window + 5min nonce dedup store | +| JWT theft via XSS | Token never sent to browser — proxy injects it server-side | +| JWT forgery | HMAC-SHA256, ≥32-char secret, issuer + expiration validation | +| Membership escalation | Rank-based `MembershipService.grant()` — cannot grant higher role than your own | +| Invitation token enumeration | 64-char URL-safe random, single-use, expires after 7d (configurable) | +| Access request DoS | Rate-limit 3 per user per day | +| Replay across requests | JWT is short-lived (15min); refresh tokens are rotated | +| Audit gap | All identity-changing ops emit revinfo with `actor_user_id`; failed logins emit `login_event` | + +Full threat model lives in `Sprint-0-Plan.md` § "Security review checklist". + +--- + +## 11. What this document is not + +- ❌ Not a step-by-step extraction plan → see [`Sprint-0-Plan.md`](Sprint-0-Plan.md) +- ❌ Not a per-API specification → generated from JavaDoc / TSDoc at v0.2 +- ❌ Not the threat model in full → see Sprint-0-Plan § Security + +It is the **shape** of the system. The rest of the wiki fills in the pixels.