Skip to content

fix(auth): useIsWorkspaceAdmin reads the session's published positions, restoring platform-admin detection - #5430

Merged
os-support-ai merged 2 commits into
mainfrom
claude/issue-5389-workspace-admin-positions
Aug 20, 2026
Merged

os-support-ai merged 2 commits into
mainfrom
claude/issue-5389-workspace-admin-positions

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes #5389

Local gate union re-run on the final commit 8623e13ea — see "Verification" below. Nothing here waits on CI; the PM reads the real gate conclusions after this lands as a draft.

The card said there was no runtime reproduction. There is one now.

The dispatch order made measuring the first step, so before touching anything I booted a real single-tenant server on the published @objectstack/cli / @objectstack/plugin-auth 17.1.0 (objectstack dev --seed-admin --fresh, banner reports Tenancy: single, no organizations plugin among the 30 loaded) and built the exact deployment shape the card names: a platform administrator whose adminship comes from the admin_full_access permission set (a sys_user_permission_set row with organization_id = NULL, what bootstrapPlatformAdmin seeds), with no sys_member row and no admin scalar on the user row.

GET /api/v1/auth/get-session answered:

{
  "user": {
    "id": "gwDZFnqdUQGFnOUh3IRWdWqykCw33eVl",
    "email": "psadmin@example.com",
    "role": "user",
    "positions": ["user", "platform_admin"],
    "isPlatformAdmin": true
  },
  "session": { "activeOrganizationId": null }
}

Object.prototype.hasOwnProperty.call(user, 'roles') on that payload answers false, and organization/get-active-member answers 400 NO_ACTIVE_ORGANIZATION. So all three legs of the hook missed:

leg reads value on the wire verdict
1 activeMember.role no member row at all no
2 user.role "user" no
3 user.roles key absent (ADR-0090 D3 renamed it) no

The hook returned false for a platform administrator: Setup and Studio vanish, the marketplace gates shut, the "Build an app" CTAs disappear. The premise holds and the defect is real. The captured payload is now the test fixture, verbatim.

The producer half is confirmed at the artifact too, not just at the wire: @objectstack/plugin-auth@17.1.0's bundled customSession returns exactly user: { ...user, positions, isPlatformAdmin: platformAdmin, ...authGate ? { authGate } : {} }, and no roles key appears anywhere in its emission path.

The fix

Leg 3 now reads user.positions[] — the one spelling the session publishes, and one this repository does not hand-copy: it is inherited from the spec's own AuthUser through AuthUser extends SpecAuthUser, so it cannot drift the way the mirror key did.

Deliberately not done, per the card and AGENTS.md commandment #0.1:

  • No roles ?? positions dual spelling. ADR-0090 D3 renamed with no deprecation window and bans reviving the old name as a fallback. A negative case pins this: a session carrying only the retired spelling reads as not an administrator. That case failed before the fix (the old leg 3 honoured it) and passes now.
  • isPlatformAdmin is not read alongside positions. The server computes it as 'platform_admin' in positions in the same expression, so the two cannot usefully disagree — reading both would be two spellings for one fact, and positions additionally covers the org_owner / org_admin names that the boolean does not.

Nobody new becomes an administrator

This is an authorization-adjacent read, and the positive case alone cannot distinguish a restored detection from a widened one — "return true more often" also makes it green. Four negative cases exist to make that substitution fail:

  • an ordinary member (positions: ["user", "org_member"], member row with role member) reads as not admin through every leg;
  • an empty positions array admits nobody;
  • a session carrying no positions key at all admits nobody;
  • the retired roles spelling alone confers nothing.

And the two legs that were never broken get cases of their own, so a swap of one dead read for one live read cannot disturb them unnoticed: an active member row with an admin role still detects, a stored user.role admin scalar still detects, and preview mode still seeds an admin.

Worth stating because it is the reason nothing widened: positions at protocol 17 carries exactly what roles carried at 16 — the stored scalar split on commas, the active membership mapped to canonical org_owner/org_admin/org_member, and platform_admin when the permission set is held. This is a rename, so restoring the read restores the pre-rename semantics rather than adding to them.

Reverse verification (predicted before running, both directions)

Written test-first. Predicted 2 failures on the pre-fix tree: the platform-admin case (true expected, false observed) and the dead-spelling pin (false expected, true observed, because leg 3 still honoured roles). Observed exactly those two, in exactly those directions, 7 others passing:

× detects a permission-set-derived platform admin with no member row and no admin scalar
    AssertionError: expected 'false' to be 'true'
× the retired `roles` spelling alone does NOT confer admin
    AssertionError: expected 'true' to be 'false'
  Tests  2 failed | 7 passed (9)

After the fix: Tests 9 passed (9).

No build artifact sits between the edit and the thing under test, on any leg. The root vitest.config.mts aliases every @object-ui/* specifier to that package's src/, and packages/auth/src has no runtime workspace import at all — its only cross-package dependency is a type-only import from @objectstack/spec, erased before execution. So the red and the green were both read off source, and no ablation needed a rebuild to be trustworthy.

The one thing the card asked for that could not be done

The card proposed retiring the dead roles?: string[] mirror key on the client AuthUser in the same pass. It is not dead. Removing it fails compilation:

src/AuthGuard.tsx(59,60): error TS2339: Property 'includes' does not exist on type '{}'

AuthGuard's requiredRoles check still reads it, and once the declaration is gone AuthUser's index signature types the read as unknown. That is a live reader, not a leftover, so the key keeps its declaration and gains a docblock stating what it actually is — absent from the v17 session face, kept for one compile-time reader, and not to be paired with positions as a fallback.

Chasing it further would have meant editing an authorization gate the card explicitly scoped out, and requiredRoles is not a mechanical rename anyway (whether it should read positions, whether it keeps the scalar fallback, and how it relates to packages/permissions' separate roles surface are open questions). Filed separately as #5424 together with three other surviving read sites, which is where that decision belongs. Out of scope for this PR; #5424 stays open.

Also in this PR, and why each is in scope rather than filed

Both are comment-only, in the same defect class (documentation naming the removed spelling), mechanically determined by the rename, and inside the same gate family — no new verification surface:

  • packages/app-shell/src/console/home/HomePage.tsx — the "useIsWorkspaceAdmin reads ROLES" comment named in the card. The ruling it records is unchanged: the EE workspace owner it describes holds org_owner, which is in positions too, so the sentence needed a spelling correction and not a semantic one.
  • packages/app-shell/src/console/home/__tests__/HomePage.authoringCapabilityGate.test.tsx — carries the same sentence, one file over. Correcting one and leaving the other would have left the card's own stale claim standing in the file that pins the behaviour. Named here because a drive-by fix nobody points at is unreviewable scope creep; this one is pointed at.

Attribution correction for the record: the card locates that comment at apps/console/.../HomePage.tsx:82; it actually lives at packages/app-shell/src/console/home/HomePage.tsx:82.

Verification

All re-run against the final commit 8623e13ea with a clean tree, so these results describe the tree being reviewed:

gate result
vitest run packages/auth/ packages/app-shell/src/console/home/__tests__/HomePage.authoringCapabilityGate.test.tsx 19 files, 196 passed
pnpm --filter @object-ui/auth type-check (tsc --noEmit + tsc -p tsconfig.test.json) pass
eslint on all 5 changed files 0 errors (8 pre-existing no-explicit-any warnings on untouched lines)
check-changeset-presence.mjs pass
check-changeset-no-major.mjs pass — patch
check-control-bytes.mjs pass (4487 files)
check-node-esm-load.mjs --specifiers-only pass
check-package-self-import.mjs pass

Test invocation was from the repository root with repo-relative paths and no -- separator, per AGENTS.md. Heavy steps ran under the shared /tmp/os-heavy-verify.lock.

The full lint/CI farm is left to CI, which runs it exactly once regardless.

Landing

Draft, and auto-merge is deliberately not enabled — the PM lands this. target:v17, release-blocking.


Generated by Claude Code

claude added 2 commits August 20, 2026 16:35
…#5389)

Leg 3 read `user.roles`, a key the protocol-17 session face no longer
emits (framework ADR-0090 D3 renamed it to `positions`). A platform
admin derived from the `admin_full_access` permission set has no member
row (leg 1) and no admin `user.role` scalar (leg 2), so all three legs
missed and Setup/Studio disappeared for a real administrator.

Measured on a live single-tenant server at published 17.1.0 before
fixing; the captured payload is the test fixture.
…t behind

The `roles?: string[]` mirror key on the client `AuthUser` cannot be
deleted yet — `AuthGuard`'s `requiredRoles` check still compiles against
it (measured: TS2339 once the key is gone, because the index signature
then types the read as `unknown`). So it keeps its declaration and gains
a docblock that says what it actually is, pointing at the follow-up card
filed as #5424 for the four remaining read sites.

Also corrects two comments in app-shell's Home page that described
`useIsWorkspaceAdmin` as reading ROLES. Comment-only; the EE workspace
owner they describe holds `org_owner`, which is in `positions` too, so
the ruling those comments record is unchanged.

Adds the changeset.
@github-actions github-actions Bot added the tests label Aug 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 25.3 KB 350 KB
Entry file index-LbKdEwph.js —
Status PASS —

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 10.04KB 3.72KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 29.34KB 7.05KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 6.35KB 2.43KB
auth (index.js) 2.77KB 1.22KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.02KB 0.89KB
auth (useIsWorkspaceAdmin.js) 3.04KB 1.45KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 506.94KB 113.61KB
core (index.js) 4.11KB 1.62KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 159.80KB 44.34KB
fields (index.js) 237.07KB 59.46KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.42KB 1.39KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.13KB 7.63KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 30.51KB 7.57KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.95KB 10.97KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.55KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.35KB 3.31KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.42KB 1.42KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 1.81KB 0.83KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.62KB 12.83KB
plugin-charts (index.js) 64.75KB 18.37KB
plugin-chatbot (index.js) 181.21KB 43.14KB
plugin-dashboard (index.js) 128.07KB 32.77KB
plugin-designer (index.js) 212.39KB 42.83KB
plugin-detail (index.js) 241.46KB 60.56KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 124.19KB 30.20KB
plugin-gantt (index.js) 164.10KB 39.87KB
plugin-grid (index.js) 197.30KB 53.06KB
plugin-kanban (index.js) 52.93KB 14.60KB
plugin-list (index.js) 111.66KB 27.13KB
plugin-map (index.js) 20.08KB 6.62KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.49KB 11.93KB
plugin-timeline (index.js) 26.68KB 7.66KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.52KB 20.67KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 36.10KB 12.26KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.33KB 0.69KB
react (schema-input.js) 1.45KB 0.83KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (index.js) 4.77KB 2.16KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 10.76KB 3.17KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 6.92KB 2.40KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.08KB 1.53KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants