Skip to content

fix: project-scoped members API + S1 security + RC-C notifications - #1407

Open
ptone wants to merge 11 commits into
mainfrom
scion/policy-fix
Open

fix: project-scoped members API + S1 security + RC-C notifications#1407
ptone wants to merge 11 commits into
mainfrom
scion/policy-fix

Conversation

@ptone

@ptone ptone commented Aug 31, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes all 6 QA defects (D1-D6) and the S1 security finding from the authorization foundation refactor (PR #1435). The root causes were:

  • RC-A + RC-B (D1-D5): The project members UI hit the generic hub-admin /api/v1/admin/role-bindings endpoint, which returned unfiltered system-wide bindings with no role name or provenance, and required system-scoped role_binding.* permissions that project owners cannot hold.
  • S1 (security): Any hub member could enumerate all role bindings hub-wide via the admin endpoint.
  • RC-C (D6): apiFetch dispatched a global 403 toast unconditionally while components also rendered the error inline, causing duplicate notifications.

Changes

Backend — new project-scoped members API:

  • GET /api/v1/projects/{id}/members — lists project-scoped bindings with enriched roleName, source, and display names
  • POST /api/v1/projects/{id}/members — adds a member with CanDelegate escalation control
  • PATCH /api/v1/projects/{id}/members/{bindingID} — atomic role change
  • DELETE /api/v1/projects/{id}/members/{bindingID} — removes member with last-owner guard
  • All endpoints authorize via project.manage / project.read at project scope

Backend — S1 security fix:

  • Removed role_binding.read from hubMemberPermissionIDs() and hubViewerPermissionIDs()
  • Bumped role revision from 1 → 2 per PG1 contract

Frontend — project-members-editor rewrite:

  • Points all CRUD operations at the new project-scoped endpoints
  • Removed roleBindingApiAvailable fallback machinery and loadDataFallback()
  • Uses atomic PATCH for role changes (replaces non-atomic create-then-delete)

Frontend — RC-C fix (duplicate 403 notifications):

  • Added suppressAccessDeniedToast option to apiFetch
  • Fixed dead detail extraction (reads from actual {error: {code, message}} envelope)
  • Deduplicated toast handlers between app-shell.ts and chat-shell.ts

Tests:

  • 12 new tests covering GET/POST/PATCH/DELETE authorization, escalation prevention, last-owner guard, and S1 regression
  • Updated scoped_admin_test.go and seed_roles_test.go for S1 changes

Closes #1406

Test plan

  • All 12 new project members tests pass
  • Existing scoped admin tests updated and pass
  • Seed roles test updated and passes
  • Go build succeeds with no errors
  • Manual verification on scion-next VM

Scion Agent (af-pf-dev) added 11 commits August 31, 2026 13:29
Implement GET/POST/PATCH/DELETE for /api/v1/projects/{id}/members,
replacing the admin role-bindings stopgap for project member management.

Key changes:
- New handlers_project_members.go with project-scoped member CRUD
- GET returns enriched bindings with roleName, source, display names
- POST/PATCH/DELETE authorize via project.manage + CanDelegate checks
- CanDelegate uses both ProjectMembership and RoleBinding grant types
  to prevent escalation (project-admin cannot mint project-owner)
- Last-owner guard preserved on PATCH (role change) and DELETE
- PATCH performs atomic role change (delete+create, not create-then-delete)
- Route dispatched via handleProjectRoutes under /members path
- Updated stale CanDelegate wiring comment in authz_candelegate.go

Closes RC-A (D1, D2) and RC-B (D3, D4, D5) backend components.
Remove role_binding.read from hubMemberPermissionIDs and
hubViewerPermissionIDs to prevent any authenticated hub member from
enumerating all role bindings hub-wide.

This is now safe because the project members UI uses the new
project-scoped /api/v1/projects/{id}/members endpoint, which
authorizes via project.read instead of role_binding.read.

Role revision bumped from 1 → 2 for both hub-member and hub-viewer
per the PG1 contract (startup reconciliation will update permissions).

Updated scoped_admin_test.go and seed_roles_test.go to reflect that
hub members are now denied access to /api/v1/admin/role-bindings.
Rewrite the frontend project members editor to use the new
project-scoped /api/v1/projects/{id}/members endpoints instead of
the generic hub-admin /api/v1/admin/role-bindings stopgap.

Key changes:
- loadData() now hits GET /api/v1/projects/{id}/members
- Add member uses POST /api/v1/projects/{id}/members
- Change role uses atomic PATCH /api/v1/projects/{id}/members/{id}
  (replaces the non-atomic create-then-delete dance)
- Remove member uses DELETE /api/v1/projects/{id}/members/{id}
- Removed roleBindingApiAvailable flag and loadDataFallback() method
- Removed the PM1 TODO comment — the PM1 API is now implemented
- Server now returns enriched roleName and source fields

Closes RC-A and RC-B frontend components (D1-D5).
Three changes to fix the duplicate toast+inline error on 403 responses:

1. api.ts: Add suppressAccessDeniedToast option to apiFetch.
   When set, the global scion:access-denied event is not dispatched,
   allowing components with local error handling to prevent double
   notifications.

2. api.ts: Fix dead detail extraction in the 403 handler. The backend
   error envelope is {error: {code, message}}, not {resource, action,
   reason}. Now reads from the actual shape so the toast message is
   specific when it fires.

3. app-shell.ts + chat-shell.ts: Guard against double-toast when both
   shells are mounted. The first handler marks the event detail as
   _handled; the second skips if already handled.

4. project-members-editor.ts: Set suppressAccessDeniedToast on all
   mutation calls that have local error handling (add, change role,
   remove member).
12 tests covering the project-scoped members endpoints:

- GET returns project-scoped bindings with enriched roleName, source,
  and principalDisplayName fields
- GET denied for non-project-members (403)
- POST works for project-owner adding a member
- POST denied for non-project-members (403)
- POST escalation prevented: project-admin cannot mint project-owner
- POST rejects non-project-scoped roles (400)
- PATCH atomically changes member role
- PATCH enforces last-owner guard (409 LAST_OWNER)
- DELETE removes member binding
- DELETE denied for project-member (403, lacks project.manage)
- DELETE enforces last-owner guard (409 LAST_OWNER)
- S1 regression: hub-member cannot list all role bindings (403)
…y validation)

R1: Reverse PATCH handler's delete-then-create to create-then-delete,
preventing data loss if create fails after delete. A brief
duplicate-binding window is harmless; a missing-binding window would
silently demote the user.

R2: Add handler-level validation for direct-user-only roles
(project-owner, project-admin) in both POST and PATCH handlers,
returning 400 instead of letting the store's ErrDirectUserOnly surface
as 500. Add test for group principal assignment rejection.
…ship

Two issues surfaced in QA: (1) "my projects" filter missed RoleBinding-
based membership because scope=mine only queried the legacy Project.OwnerID
field, and (2) pre-existing projects showed "no members" because they
lacked project-owner RoleBindings.

Fixes:
- Add backfillProjectOwnerRoleBindings to seed.go: iterates all projects
  at startup and creates project-owner RoleBindings from Project.CreatedBy.
  Idempotent (skips existing bindings).
- Add resolveUserRBProjectIDs helper: queries user's project-scoped
  RoleBindings and returns ScopeIDs for membership resolution.
- Add mergeProjectIDs helper: deduplicates project IDs from multiple
  sources (legacy groups + RoleBindings).
- Update scope=mine, scope=shared, and mine=true filters in both project
  and agent list handlers to merge group-based and RoleBinding-based
  membership sources.
- Fix gofmt alignment in handlers_project_members.go.
… check

createProject and handleProjectRegister had no authorization enforcement.
Any authenticated user — including hub-viewers — could create projects
and, by cascading into project-owner role, create agents. Add CheckAccess
for project.create at the top of both handlers, matching the authorize()
pattern used by other mutation endpoints.
Suspended users could continue operating with valid JWT or UAT tokens
because neither path checked user.Status. Add suspension enforcement:

- UAT: ValidateToken already fetches the user — add Status check and
  return ErrUserSuspended, surfaced as 403 in the middleware.
- JWT: tokens are self-contained, so add a per-request user lookup in
  UnifiedAuthMiddleware to check current status from the store.
- Wire UserStore into AuthConfig so the middleware can query user state.

The full kernel-level suspension restriction (B1 scope) will be wired
through the Decide() pipeline on scion/constraint-uiux.
The S1 fix bumped hub-member to revision 2. Update the legacy integer
marker reconciliation test to expect the current revision.
The JWT auth path silently continued when UserStore.GetUser() returned
a non-ErrNotFound error (e.g. transient DB outage), allowing suspended
users with valid JWTs to bypass the suspension gate. Return 503 Service
Unavailable on store errors instead, making the JWT path fail-closed
like the UAT path. Only ErrNotFound (deleted user) falls through, where
downstream handlers enforce the missing-record invariant.

Addresses R1 from review round 4 (policy-fix-review-r4).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: post-merge QA defects in project membership and authorization

1 participant