diff --git a/README.md b/README.md index 01f746d..f3475ba 100644 --- a/README.md +++ b/README.md @@ -118,8 +118,12 @@ permission set (DESIGN.md §04). Each one is measured, with the measurement in the issue. None is hidden by the demo. - [#39](https://github.com/objectstack-ai/ats/issues/39) — on the default (sqlite) driver the four - tenancy-scoped objects return no rows to the platform personas; run the demo on - `--database-driver memory` (upstream [objectstack#16589](https://github.com/objectstack-ai/objectstack/issues/16589)). + tenancy-scoped objects (`ats_employer`, `ats_employer_member`, `ats_interview`, `ats_offer`) return + no rows to a session whose active organization is the Default Organization. Since the app declares + `membershipPolicy: 'invite-only'` that is the platform **owner** (`admin@objectos.ai`) alone; the + `admin@platform.example` and `ops@platform.example` personas hold no membership and read all four on + either driver. Sign in as those two, or run the demo on `--database-driver memory` + (upstream [objectstack#16589](https://github.com/objectstack-ai/objectstack/issues/16589)). - [#45](https://github.com/objectstack-ai/ats/issues/45) — an employer administrator cannot yet *create* a job (blocked upstream on [objectstack#16607](https://github.com/objectstack-ai/objectstack/issues/16607) and diff --git a/docs/evidence/issue-38/01-backfill-and-personas-memory.txt b/docs/evidence/issue-38/01-backfill-and-personas-memory.txt new file mode 100644 index 0000000..bcdc67b --- /dev/null +++ b/docs/evidence/issue-38/01-backfill-and-personas-memory.txt @@ -0,0 +1,95 @@ +# Membership backfill before / after — driver: memory, port 4621, ats @ cdbadc4 (#38) +# BEFORE = the same worktree with AtsAuthMembershipPolicyPlugin removed from objectstack.config.ts +# plugins: [] (ablation, restored byte-identically afterwards); AFTER = as committed. + +## BEFORE — boot log, the backfill line +2026-09-07T19:49:29.397Z INFO [auth] membership backfill (app:seeded) bound 82 member-less user(s) to the default organization (ADR-0093 D6) {"scanned":113,"bound":82,"skipped":31} + +## AFTER — boot log, every line mentioning the policy or the backfill +2026-09-07T19:50:24.397Z INFO [ats] auth membership policy declared 'invite-only' (was 'auto') in init() — DESIGN.md §03: sign-up and the ADR-0093 D6 backfill bind nobody to the default organization. An explicit auth.membership_policy setting or OS_AUTH_MEMBERSHIP_POLICY still overrides this at kernel:ready. +(the ADR-0093 D6 backfill logs only when bound > 0 — plugin-auth/src/auth-plugin.ts; + the absence of the line IS the 0-bound reading, and the ablation above is its control) + +## BEFORE — read as the platform owner +## sign-in owner: HTTP 200 +sys_member: total=113 hasMore=false len=113 consistent=true +sys_member per organization: + org_mtrnmk8eefixzedy 83 + org_ats_quillstone 3 + org_ats_harborline 3 + org_ats_cedarbrook 3 + org_ats_brightmarket 3 + org_ats_lumenvale 3 + org_ats_summitridge 3 + org_ats_swiftroute 2 + org_ats_bluewater 2 + org_ats_ironbridge 2 + org_ats_pixelforge 2 + org_ats_meridian 2 + org_ats_orbit 2 +sys_organization: total=13 hasMore=false len=13 consistent=true +organizations: org_ats_quillstone(Quillstone Robotics), org_ats_harborline(Harborline Manufacturing), org_ats_cedarbrook(Cedarbrook Care Network), org_ats_brightmarket(Brightmarket Retail Group), org_ats_lumenvale(Lumenvale Academy), org_ats_summitridge(Summitridge Financial), org_ats_swiftroute(Swiftroute Logistics), org_ats_bluewater(Bluewater Hospitality Group), org_ats_ironbridge(Ironbridge Construction), org_ats_pixelforge(Pixelforge Studios), org_ats_meridian(Meridian Clinics), org_ats_orbit(Orbit Consulting Partners), org_mtrnmk8eefixzedy(Default Organization) +sys_user: total=113 hasMore=false len=113 consistent=true + +## per-persona reads (total / hasMore / len) +persona employer employer_member interview offer job application candidate inquiry +owner 12 30 40 23 40 200 80 8 + activeOrganizationId="org_mtrnmk8eefixzedy" userId=usr_ats_0_owner +platform-admin 12 30 40 23 40 200 80 8 + activeOrganizationId="org_mtrnmk8eefixzedy" userId=usr_ats_platform_admin +platform-ops 12 30 40 23 40 200 80 8 + activeOrganizationId="org_mtrnmk8eefixzedy" userId=usr_ats_platform_ops +quillstone-admin 1 3 10 3 5 27 70 2 + activeOrganizationId="org_ats_quillstone" userId=usr_ats_quillstone_admin +quillstone-rec 1 3 10 3 5 27 70 2 + activeOrganizationId="org_ats_quillstone" userId=usr_ats_quillstone_r1 +harborline-admin 1 3 6 4 5 31 69 2 + activeOrganizationId="org_ats_harborline" userId=usr_ats_harborline_admin +seeker-01 9 ERR 0 1 22 1 1 ERR + activeOrganizationId="org_mtrnmk8eefixzedy" userId=usr_ats_c01 + ! ats_employer_member: HTTP 403 {"error":"You do not have permission to perform this action. Contact your administrator if you need access.","code":"PERMISSION_DENIED","object":"ats_employer_member"} + ! ats_inquiry: HTTP 403 {"error":"You do not have permission to perform this action. Contact your administrator if you need access.","code":"PERMISSION_DENIED","object":"ats_inquiry"} + +# end + +## AFTER — read as the platform owner +## sign-in owner: HTTP 200 +sys_member: total=31 hasMore=false len=31 consistent=true +sys_member per organization: + org_ats_quillstone 3 + org_ats_harborline 3 + org_ats_cedarbrook 3 + org_ats_brightmarket 3 + org_ats_lumenvale 3 + org_ats_summitridge 3 + org_ats_swiftroute 2 + org_ats_bluewater 2 + org_ats_ironbridge 2 + org_ats_pixelforge 2 + org_ats_meridian 2 + org_ats_orbit 2 + org_mtrnnrr350qhbok2 1 +sys_organization: total=13 hasMore=false len=13 consistent=true +organizations: org_ats_quillstone(Quillstone Robotics), org_ats_harborline(Harborline Manufacturing), org_ats_cedarbrook(Cedarbrook Care Network), org_ats_brightmarket(Brightmarket Retail Group), org_ats_lumenvale(Lumenvale Academy), org_ats_summitridge(Summitridge Financial), org_ats_swiftroute(Swiftroute Logistics), org_ats_bluewater(Bluewater Hospitality Group), org_ats_ironbridge(Ironbridge Construction), org_ats_pixelforge(Pixelforge Studios), org_ats_meridian(Meridian Clinics), org_ats_orbit(Orbit Consulting Partners), org_mtrnnrr350qhbok2(Default Organization) +sys_user: total=113 hasMore=false len=113 consistent=true + +## per-persona reads (total / hasMore / len) +persona employer employer_member interview offer job application candidate inquiry +owner 12 30 40 23 40 200 80 8 + activeOrganizationId="org_mtrnnrr350qhbok2" userId=usr_ats_0_owner +platform-admin 12 30 40 23 40 200 80 8 + activeOrganizationId=null userId=usr_ats_platform_admin +platform-ops 12 30 40 23 40 200 80 8 + activeOrganizationId=null userId=usr_ats_platform_ops +quillstone-admin 1 3 10 3 5 27 70 2 + activeOrganizationId="org_ats_quillstone" userId=usr_ats_quillstone_admin +quillstone-rec 1 3 10 3 5 27 70 2 + activeOrganizationId="org_ats_quillstone" userId=usr_ats_quillstone_r1 +harborline-admin 1 3 6 4 5 31 69 2 + activeOrganizationId="org_ats_harborline" userId=usr_ats_harborline_admin +seeker-01 9 ERR 0 1 22 1 1 ERR + activeOrganizationId=null userId=usr_ats_c01 + ! ats_employer_member: HTTP 403 {"error":"You do not have permission to perform this action. Contact your administrator if you need access.","code":"PERMISSION_DENIED","object":"ats_employer_member"} + ! ats_inquiry: HTTP 403 {"error":"You do not have permission to perform this action. Contact your administrator if you need access.","code":"PERMISSION_DENIED","object":"ats_inquiry"} + +# end diff --git a/docs/evidence/issue-38/02-backfill-and-personas-sqlite.txt b/docs/evidence/issue-38/02-backfill-and-personas-sqlite.txt new file mode 100644 index 0000000..f5e1f39 --- /dev/null +++ b/docs/evidence/issue-38/02-backfill-and-personas-sqlite.txt @@ -0,0 +1,89 @@ +# Membership backfill before / after — driver: sqlite (persistent file), port 4621, ats @ cdbadc4 (#38) +# The REST sys_member census is itself organization-filtered on sqlite, so the authoritative +# counts below are read straight out of the database file with better-sqlite3. + +## BEFORE — boot log, the backfill line +2026-09-07T19:49:52.246Z INFO [auth] membership backfill (app:seeded) bound 82 member-less user(s) to the default organization (ADR-0093 D6) {"scanned":113,"bound":82,"skipped":31} +## BEFORE — sys_member, read directly from the sqlite file +# direct sqlite read — /tmp/claude-0/-home-user/b5e239b4-1c2d-5c61-92c7-2d1a1706b178/scratchpad/issue-38/b4.db +sys_member total: 113 +sys_user total: 113 +per organization: + org_mtrnn1kpyt1xfpth 83 + org_ats_brightmarket 3 + org_ats_cedarbrook 3 + org_ats_harborline 3 + org_ats_lumenvale 3 + org_ats_quillstone 3 + org_ats_summitridge 3 + org_ats_bluewater 2 + org_ats_ironbridge 2 + org_ats_meridian 2 + org_ats_orbit 2 + org_ats_pixelforge 2 + org_ats_swiftroute 2 + +## AFTER — boot log, every line mentioning the policy or the backfill +2026-09-07T19:50:46.199Z INFO [ats] auth membership policy declared 'invite-only' (was 'auto') in init() — DESIGN.md §03: sign-up and the ADR-0093 D6 backfill bind nobody to the default organization. An explicit auth.membership_policy setting or OS_AUTH_MEMBERSHIP_POLICY still overrides this at kernel:ready. +## AFTER — sys_member, read directly from the sqlite file +# direct sqlite read — /tmp/claude-0/-home-user/b5e239b4-1c2d-5c61-92c7-2d1a1706b178/scratchpad/issue-38/a4.db +sys_member total: 31 +sys_user total: 113 +per organization: + org_ats_brightmarket 3 + org_ats_cedarbrook 3 + org_ats_harborline 3 + org_ats_lumenvale 3 + org_ats_quillstone 3 + org_ats_summitridge 3 + org_ats_bluewater 2 + org_ats_ironbridge 2 + org_ats_meridian 2 + org_ats_orbit 2 + org_ats_pixelforge 2 + org_ats_swiftroute 2 + org_mtrno906rwpuxijt 1 + +## BEFORE — per-persona reads +## per-persona reads (total / hasMore / len) +persona employer employer_member interview offer job application candidate inquiry +owner 0 0 0 0 40 200 80 8 + activeOrganizationId="org_mtrnn1kpyt1xfpth" userId=usr_ats_0_owner +platform-admin 0 0 0 0 40 200 80 8 + activeOrganizationId="org_mtrnn1kpyt1xfpth" userId=usr_ats_platform_admin +platform-ops 0 0 0 0 40 200 80 8 + activeOrganizationId="org_mtrnn1kpyt1xfpth" userId=usr_ats_platform_ops +quillstone-admin 1 3 10 3 5 27 70 2 + activeOrganizationId="org_ats_quillstone" userId=usr_ats_quillstone_admin +quillstone-rec 1 3 10 3 5 27 70 2 + activeOrganizationId="org_ats_quillstone" userId=usr_ats_quillstone_r1 +harborline-admin 1 3 6 4 5 31 69 2 + activeOrganizationId="org_ats_harborline" userId=usr_ats_harborline_admin +seeker-01 0 ERR 0 0 22 1 1 ERR + activeOrganizationId="org_mtrnn1kpyt1xfpth" userId=usr_ats_c01 + ! ats_employer_member: HTTP 403 {"error":"You do not have permission to perform this action. Contact your administrator if you need access.","code":"PERMISSION_DENIED","object":"ats_employer_member"} + ! ats_inquiry: HTTP 403 {"error":"You do not have permission to perform this action. Contact your administrator if you need access.","code":"PERMISSION_DENIED","object":"ats_inquiry"} + +# end + +## AFTER — per-persona reads +## per-persona reads (total / hasMore / len) +persona employer employer_member interview offer job application candidate inquiry +owner 0 0 0 0 40 200 80 8 + activeOrganizationId="org_mtrno906rwpuxijt" userId=usr_ats_0_owner +platform-admin 12 30 40 23 40 200 80 8 + activeOrganizationId=null userId=usr_ats_platform_admin +platform-ops 12 30 40 23 40 200 80 8 + activeOrganizationId=null userId=usr_ats_platform_ops +quillstone-admin 1 3 10 3 5 27 70 2 + activeOrganizationId="org_ats_quillstone" userId=usr_ats_quillstone_admin +quillstone-rec 1 3 10 3 5 27 70 2 + activeOrganizationId="org_ats_quillstone" userId=usr_ats_quillstone_r1 +harborline-admin 1 3 6 4 5 31 69 2 + activeOrganizationId="org_ats_harborline" userId=usr_ats_harborline_admin +seeker-01 9 ERR 0 1 22 1 1 ERR + activeOrganizationId=null userId=usr_ats_c01 + ! ats_employer_member: HTTP 403 {"error":"You do not have permission to perform this action. Contact your administrator if you need access.","code":"PERMISSION_DENIED","object":"ats_employer_member"} + ! ats_inquiry: HTTP 403 {"error":"You do not have permission to perform this action. Contact your administrator if you need access.","code":"PERMISSION_DENIED","object":"ats_inquiry"} + +# end diff --git a/docs/evidence/issue-38/03-precedence-persistence-and-admin-create-user.txt b/docs/evidence/issue-38/03-precedence-persistence-and-admin-create-user.txt new file mode 100644 index 0000000..1b7fc6e --- /dev/null +++ b/docs/evidence/issue-38/03-precedence-persistence-and-admin-create-user.txt @@ -0,0 +1,63 @@ +# Precedence and persistence — port 4621, ats @ cdbadc4 (#38) + +## 1. An explicit OS_AUTH_MEMBERSHIP_POLICY overrides the app declaration (driver: memory, --fresh) +# boot env: OS_AUTH_MEMBERSHIP_POLICY=auto +2026-09-07T19:47:12.565Z INFO [ats] auth membership policy declared 'invite-only' (was 'auto') in init() — DESIGN.md §03: sign-up and the ADR-0093 D6 backfill bind nobody to the default organization. An explicit auth.membership_policy setting or OS_AUTH_MEMBERSHIP_POLICY still overrides this at kernel:ready. +2026-09-07T19:47:14.058Z INFO [auth] membership backfill (app:seeded) bound 82 member-less user(s) to the default organization (ADR-0093 D6) {"scanned":113,"bound":82,"skipped":31} +# The app patches in init(); AuthPlugin's settings binding runs at kernel:ready, AFTER it, and +# applies the env value because its source is not 'default' (isExplicit, auth-plugin.ts). The +# backfill then binds the 82 again — operator override > app declaration > platform default. +sys_member: total=113 hasMore=false len=113 consistent=true +sys_member per organization: + org_mtrnjnrybobmaknn 83 + org_ats_quillstone 3 + org_ats_harborline 3 + org_ats_cedarbrook 3 + org_ats_brightmarket 3 + org_ats_lumenvale 3 + org_ats_summitridge 3 + org_ats_swiftroute 2 + org_ats_bluewater 2 + org_ats_ironbridge 2 + org_ats_pixelforge 2 + org_ats_meridian 2 + org_ats_orbit 2 + +## 2. The declaration holds on a SECOND boot over a persistent sqlite file (no --fresh) +2026-09-07T19:46:42.011Z INFO [Seeder] Seed loading complete {"inserted":0,"updated":7,"skipped":811,"errored":0} +2026-09-07T19:46:40.811Z INFO [ats] auth membership policy declared 'invite-only' (was 'auto') in init() — DESIGN.md §03: sign-up and the ADR-0093 D6 backfill bind nobody to the default organization. An explicit auth.membership_policy setting or OS_AUTH_MEMBERSHIP_POLICY still overrides this at kernel:ready. +# sys_member read straight out of the file is unchanged across the two boots: 32 = 30 employer +# staff + the platform owner + the one user the admin create-user probe (section 3) created. +# direct sqlite read — /tmp/claude-0/-home-user/b5e239b4-1c2d-5c61-92c7-2d1a1706b178/scratchpad/issue-38/ats38.db +sys_member total: 32 +sys_user total: 114 +per organization: + org_ats_brightmarket 3 + org_ats_cedarbrook 3 + org_ats_harborline 3 + org_ats_lumenvale 3 + org_ats_quillstone 3 + org_ats_summitridge 3 + org_ats_bluewater 2 + org_ats_ironbridge 2 + org_ats_meridian 2 + org_ats_orbit 2 + org_ats_pixelforge 2 + org_ats_swiftroute 2 + org_mtrngrqg5miihcxu 2 +setting tables: ["sys_setting","sys_setting_audit"] + sys_setting: 0 row(s); mentioning "membership": 0 + sys_setting_audit: 0 row(s); mentioning "membership": 0 + +## 3. UPSTREAM: the admin create-user endpoint binds regardless of the policy +# plugin-auth/src/admin-user-endpoints.ts, bindUserToSoleOrganization() calls +# reconcileMembership(engine, userId, { policy: 'auto', ... }) with the policy HARD-CODED, +# so it never reads getMembershipPolicy(). Measured on the branch, sqlite, invite-only: +POST /api/v1/auth/admin/create-user (probe.1788810309814@mail.example) → HTTP 200 {"success":true,"data":{"user":{"id":"raaDvTKgNbjzngvweCrTt1RuC0u6prST","email":"probe.1788810309814@mail.example","name":"Membership Probe"},"placeholderEmail":false,"mustChangePassword":true,"organizationId":"org_mtrngrqg5miihcxu","membershipCreated":true}} +sys_member AFTER create-user: total=2 hasMore=false len=2 consistent=true +delta: 1 +new user session activeOrganizationId="org_mtrngrqg5miihcxu" +# Reported, not fixed here: it is upstream, and it is arguably right (an operator creating an +# account IS an invitation) — but it is not what ADR-0093 D1's 'the ONE source both membership +# paths read' says, and on this app it puts a Setup-created user in the Default Organization +# even though the deployment says invite-only. The demo never calls it. diff --git a/docs/evidence/issue-38/README.md b/docs/evidence/issue-38/README.md new file mode 100644 index 0000000..f800615 --- /dev/null +++ b/docs/evidence/issue-38/README.md @@ -0,0 +1,22 @@ +# Evidence — #38, the app declares `membershipPolicy: 'invite-only'` + +Every file was produced on `claude/issue-38-invite-only`, `@objectstack/cli` 17.3.0, port 4621, with +`OS_PLATFORM_OWNER_EMAIL=admin@objectos.ai npx objectstack dev --fresh --log-level info`. Counts were +taken only after `[Seeder] Seed loading complete`; REST list reads assert `hasMore == false` and +`len(records) == total` before they are counted. + +**BEFORE** is not `origin/main`: it is this same branch with `AtsAuthMembershipPolicyPlugin` removed +from the `plugins` array of `objectstack.config.ts` and the artifact rebuilt (`Runtime: 2 plugins`), +so the only difference between the two columns is the declaration itself. The file was restored with +`git checkout HEAD -- objectstack.config.ts` and verified byte-identical (`git hash-object` back to +`a712287646e095fc1e37716a5efa847ac8de60ba`, `git diff HEAD` clean) after each pass. + +| File | What it holds | +|:--|:--| +| `01-backfill-and-personas-memory.txt` | Driver `memory`: the `bound 82` backfill line before, its absence after, and the seven-persona read table on both sides. | +| `02-backfill-and-personas-sqlite.txt` | Driver `sqlite`: the same pair, with `sys_member` read straight out of the database file (the REST census is itself organization-filtered on sqlite). | +| `03-precedence-persistence-and-admin-create-user.txt` | `OS_AUTH_MEMBERSHIP_POLICY=auto` overriding the declaration; a second boot over a persistent sqlite file; and the upstream admin create-user path that binds regardless of the policy. | + +The backfill logs **only when it binds someone** (`if (res.bound > 0)`, `plugin-auth/src/auth-plugin.ts`), +so "0 bound" has no line of its own — the absence of the line is the reading, and the BEFORE column +is its control. diff --git a/objectstack.config.ts b/objectstack.config.ts index ae02b8c..a712287 100644 --- a/objectstack.config.ts +++ b/objectstack.config.ts @@ -22,6 +22,7 @@ import { JobSeekerSet, registerAtsPositionBindings, AtsRlsMembershipResolverPlugin, + AtsAuthMembershipPolicyPlugin, } from './src/security/index.js'; /** @@ -125,10 +126,17 @@ export default defineStack({ // runs, so only a plugin's `init()` (Phase 1) lands the service in time. // In-repo code, no new package (see rls-membership-resolver.ts). // + // The membership-policy plugin declares `membershipPolicy: 'invite-only'` on + // the live AuthManager, also in `init()` and for the same phase reason: the + // ADR-0093 D6 backfill runs at `kernel:ready`/`app:seeded`, and on the + // platform default (`auto`) it binds every member-less user — the 80 seekers + // and 2 platform staff — to the Default Organization, which DESIGN.md §03 + // forbids (#38; see auth-membership-policy.ts). + // // The demo-seed gate plugin only logs: one line per boot saying whether the // demo seed (and its 7 logins) loads under this NODE_ENV, and what toggles // it — warn when skipped, because the CLI's default log level hides info. - plugins: [AtsRlsMembershipResolverPlugin, AtsDemoSeedGatePlugin], + plugins: [AtsRlsMembershipResolverPlugin, AtsAuthMembershipPolicyPlugin, AtsDemoSeedGatePlugin], }); /** diff --git a/src/security/auth-membership-policy.ts b/src/security/auth-membership-policy.ts new file mode 100644 index 0000000..89b0a45 --- /dev/null +++ b/src/security/auth-membership-policy.ts @@ -0,0 +1,190 @@ +/** + * The app-owned declaration of the auth membership policy — how this + * deployment states, at author time, that signing in or being seeded does NOT + * bind you to an organization (DESIGN.md §03). + * + * ## What goes wrong without it + * + * `AuthManager.getMembershipPolicy()` is `this.config.membershipPolicy ?? 'auto'`, + * and under `auto` two paths bind: + * + * - sign-up — the reconciler composed into better-auth's `user.create.after`; + * - the ADR-0093 D6 backfill — a pass over every pre-existing member-less + * user, run from AuthPlugin's `kernel:ready` AND `app:seeded` hooks. + * + * Measured on this repo at `e759ba5` with the demo seed, before this plugin + * existed: + * + * [auth] membership backfill (app:seeded) bound 82 member-less user(s) to + * the default organization (ADR-0093 D6) {"scanned":113,"bound":82,"skipped":31} + * + * The 82 are the 80 job seekers and the 2 platform staff — exactly the people + * the seed leaves member-less on purpose. DESIGN.md §03 rules that out in as + * many words: 「`membershipPolicy` 必须是 **`invite-only`**(不自动绑组织)」, + * because an organization is this platform's isolation boundary and a shared + * one is a shared identity. It is not cosmetic: the employer-side policies key + * on membership through `current_user.employer_org_ids` + * (see rls-membership-resolver.ts), so every member-less seeker carrying the + * default organization is a seeker inside a tenant. + * + * ## Why a kernel plugin, and why `init()` + * + * The policy is an auth-plugin setting (ADR-0093 D1), not a `defineStack()` + * key, and it is read LIVE through `getMembershipPolicy()` — never off a + * captured constructor option. `AuthManager.applyConfigPatch()` is the public + * seam that targets exactly the config that accessor reads, and the manager is + * registered as the `auth` service inside `AuthPlugin.init()`. + * + * Kernel phases are: every plugin's `init()`, then every `start()`, then + * `trigger('kernel:ready')`. Patching in `init()` therefore lands before the + * backfill under any composition order, and `optionalDependencies` below makes + * "AuthPlugin initializes first" a declared contract (ADR-0116) rather than an + * accident of the CLI's registration order. `start()` is the fallback for a + * composition that inits us first anyway: still a whole phase before + * `kernel:ready`. + * + * ## Routes deliberately not taken + * + * - Constructing `AuthPlugin` in `objectstack.config.ts`: the CLI stands down + * entirely when the app supplies one (`serve.ts`, `hasAuthPlugin`), so the + * app would inherit the secret, base URL, trusted origins, social providers, + * the admin plugin and the cookie domain — six ways to break the demo to + * move one flag. + * - Seeding a `sys_setting` row: that object is `managedBy: 'engine-owned'`. + * - `OS_SKIP_MEMBERSHIP_BACKFILL=1`: it silences the backfill only. Sign-up + * keeps auto-binding, so the deviation survives with its evidence removed. + * + * ## Who wins if the deployment says otherwise + * + * The operator does, and deliberately. AuthPlugin binds the `auth` settings + * namespace at `kernel:ready` — after this patch — but applies a value only + * when it is EXPLICIT (a stored `auth.membership_policy` row or + * `OS_AUTH_MEMBERSHIP_POLICY`); a manifest default is a UI default and is not + * applied. So the precedence is: explicit deployment setting > this + * declaration > the platform default `auto`. That is the right order — this + * file is the app's default posture, not a lock, and an operator who sets the + * platform setting has said something more specific than the app can. + * + * ## This route is unpinned upstream + * + * Nothing in the framework — no app, example, template or test — declares this + * policy at author time today, so no upstream test fails if `applyConfigPatch` + * or the service name moves. The verification below is this app's own pin: the + * policy is read back through the same accessor the backfill reads, and a + * patch that did not take fails the boot instead of booting into `auto`. + */ + +import type { Plugin } from '@objectstack/spec/contracts'; + +/** DESIGN.md §03: seekers belong to no organization. One of `['auto', 'invite-only']`. */ +export const ATS_MEMBERSHIP_POLICY = 'invite-only'; + +/** The service name `AuthPlugin.init()` registers its `AuthManager` under. */ +const AUTH_SERVICE = 'auth'; + +/** The plugin whose `init()` provides it — an order-if-present edge, not a hard dependency. */ +const AUTH_PLUGIN_NAME = 'com.objectstack.auth'; + +/** + * The slice of `AuthManager` this file touches. Declared structurally rather + * than imported: `@objectstack/plugin-auth` is not a dependency of this app + * (the CLI composes it), and taking one on to read two method signatures would + * pin a package the app otherwise never names. + */ +interface AuthManagerLike { + applyConfigPatch(patch: { membershipPolicy: string }): void; + getMembershipPolicy(): string; +} + +/** The slice of the kernel `PluginContext` this plugin touches. */ +interface PolicyHostContext { + getService?: (name: string) => unknown; + logger?: { info?: (...a: unknown[]) => void; warn?: (...a: unknown[]) => void; error?: (...a: unknown[]) => void }; +} + +/** Set once the patch has been applied and read back, so `start()` knows to stand down. */ +let applied = false; + +function authManager(ctx: PolicyHostContext): AuthManagerLike | undefined { + let svc: unknown; + try { + svc = ctx.getService?.(AUTH_SERVICE); + } catch { + return undefined; // not registered yet — the caller decides whether that is fatal + } + if (!svc) return undefined; + const candidate = svc as Partial; + if (typeof candidate.applyConfigPatch !== 'function' || typeof candidate.getMembershipPolicy !== 'function') { + // The service exists but is not the shape this seam needs. Never silently + // continue: the deployment would boot on `auto` and bulk-bind. + throw new Error( + `[ats] the '${AUTH_SERVICE}' service does not expose applyConfigPatch()/getMembershipPolicy() — ` + + `this app cannot declare membershipPolicy='${ATS_MEMBERSHIP_POLICY}' (DESIGN.md §03) and would boot on the ` + + `platform default 'auto', which binds every member-less user to the default organization.`, + ); + } + return candidate as AuthManagerLike; +} + +/** + * Patch and verify. Returns `false` when the `auth` service is not registered + * yet — the only recoverable miss, and the reason `start()` retries. + */ +function declarePolicy(ctx: PolicyHostContext, phase: 'init' | 'start'): boolean { + const manager = authManager(ctx); + if (!manager) return false; + + const before = manager.getMembershipPolicy(); + if (before !== ATS_MEMBERSHIP_POLICY) manager.applyConfigPatch({ membershipPolicy: ATS_MEMBERSHIP_POLICY }); + const after = manager.getMembershipPolicy(); + + if (after !== ATS_MEMBERSHIP_POLICY) { + // The seam moved. Fail the boot rather than leave the log line as the only + // notice — an `auto` boot binds in BULK and the rows outlive the process. + throw new Error( + `[ats] membership policy is still '${after}' after applyConfigPatch({ membershipPolicy: '${ATS_MEMBERSHIP_POLICY}' }) ` + + `— DESIGN.md §03 requires '${ATS_MEMBERSHIP_POLICY}'. Refusing to boot: on '${before}' the ADR-0093 D6 backfill ` + + `binds every member-less user (80 seekers and 2 platform staff on the demo seed) to the default organization.`, + ); + } + + applied = true; + ctx.logger?.info?.( + `[ats] auth membership policy declared '${ATS_MEMBERSHIP_POLICY}' (was '${before}') in ${phase}() — DESIGN.md §03: ` + + 'sign-up and the ADR-0093 D6 backfill bind nobody to the default organization. An explicit ' + + 'auth.membership_policy setting or OS_AUTH_MEMBERSHIP_POLICY still overrides this at kernel:ready.', + ); + return true; +} + +/** + * Declares {@link ATS_MEMBERSHIP_POLICY} on the live `AuthManager` in Phase 1, + * before the ADR-0093 D6 backfill runs at `kernel:ready` / `app:seeded`. + * Declared in `objectstack.config.ts` → `plugins`. + */ +export const AtsAuthMembershipPolicyPlugin = { + name: 'ats.auth-membership-policy', + version: '0.1.0', + type: 'standard', + // Order-if-present: hoists AuthPlugin ahead of this one when it is composed + // (it always is, under the CLI), and is skipped when it is not. Not + // `requiresServices: ['auth']`, which would refuse to boot a kernel that + // composes no auth at all — a kernel that also runs no backfill. + optionalDependencies: [AUTH_PLUGIN_NAME], + init(ctx: PolicyHostContext): void { + declarePolicy(ctx, 'init'); + }, + start(ctx: PolicyHostContext): void { + if (applied) return; + if (declarePolicy(ctx, 'start')) return; + // No `auth` service after every plugin has initialized: AuthPlugin is not + // composed at all, so there is no reconciler and no backfill to govern — + // nothing to enforce, and nothing that can bind. Loud anyway, because the + // alternative reading (the seam moved) looks identical from here. + ctx.logger?.error?.( + `[ats] no '${AUTH_SERVICE}' service was registered, so membershipPolicy='${ATS_MEMBERSHIP_POLICY}' (DESIGN.md §03) ` + + 'could NOT be declared. Nothing binds either — AuthPlugin owns both the sign-up reconciler and the ADR-0093 D6 ' + + 'backfill — so this boot is consistent, but if sign-in works here the declaration has silently stopped applying.', + ); + }, +} satisfies Plugin; diff --git a/src/security/index.ts b/src/security/index.ts index 6f50efa..6c6553b 100644 --- a/src/security/index.ts +++ b/src/security/index.ts @@ -21,3 +21,8 @@ export { AtsRlsMembershipResolverPlugin, EMPLOYER_ORG_IDS_KEY, } from './rls-membership-resolver.js'; + +export { + AtsAuthMembershipPolicyPlugin, + ATS_MEMBERSHIP_POLICY, +} from './auth-membership-policy.js';