B7c: no inline drive access gates outside packages/lib/src/permissions (org-aware primitives + per-query seam guard) - #2689
Conversation
… the former inline gates (B7c) loadDriveRelationship(s) answer lead + org-aware effective membership (batch: constant queries); listMemberDrives / getMemberDriveIds / memberOfAnyDriveCondition answer 'which drives is this person a member of' with the listing rule, dark = owned + accepted rows. Integration suite checks every Northwind person x drive against getDriveAccess and listAccessibleDrives, and dark against the frozen inline union. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
…py/move gate on the org-aware drive relationship (B7c) Each route asked drives.ownerId and its own drive_members read; they now ask loadDriveRelationship (lead + effective membership) and keep their own role test, so a personal drive answers as before while an org Admin, an implicit Open member and a stale source='org' row resolve like every lib resolver. The drive pages route's non-admin branch takes getUserAccessiblePagesInDrive's page set. Pure decisions (drive-relationship.ts) split from the IO loader. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
…and ownership transfer ask the org-aware relationship (B7c) page-reorder-service and permission-management-service take master's isDriveOwnerOrAdmin fix (#2672 line) plus org cases; the drive invite route gates on canAdministerDrive (findAdminMembership removed); the revoke port answers the actor's effective drive role (findActorDriveRole) so the lead of an org drive, who has no OWNER row, can revoke and a stale org row cannot; handle-drive's transfer target must be an effective ADMIN. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
… scope to the org-aware member-drive set (B7c) Each ran its own owned-drives + accepted-rows union; they now call getMemberDriveIds, which is that exact union while ORGS_ENABLED is off and the listing rule (decideListedDriveRole without page shares) while on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
…the org-aware member-drive set (B7c) They read accepted drive_members rows only, so an org drive's lead (who never gets an OWNER self-heal row) and an implicit Open member had no drive context, while a stale org row still counted. getMemberDriveIds answers owned + joined, org-aware. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
…te drives from the org-aware member-drive set (B7c) The candidate SQL joined drive_members and compared drives.ownerId; it now matches p.driveId = ANY(getMemberDriveIds) (owned or joined, org-aware) or a live page grant. messages/threads gains the getBatchPagePermissions filter the inbox and badges already had (master #2672 line), so a pending invitee, a stale org row or a private channel no longer lists a channel's title and last message. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
…permissions layer (B7c) callerCanViewUser asks sharesMemberDrive and searchRelatedProfilesByName filters with memberOfAnyDriveCondition (both org-aware; the SQL condition is checked against the decision for every Northwind person and drive), instead of their own owned-drives and drive_members reads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
… listed role (B7c) fetchShellDriveIds ran owned + accepted rows and took the role from the raw row; the shell now lists listMemberDrives (same union while dark, the listing rule while on) inside its transaction, and the ownership flag and role come from that listing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
…emberships in one batch (B7c) The sandbox-eligibility edit rule read raw drive_members rows; it now reads loadDriveRelationships (an org Admin is ADMIN, an implicit Open member carries the drive's default role, a stale org row gives nothing), and the lead check is isDriveLead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
… and event attendees; lib owner checks ask isDriveLead (B7c) listDriveAudience(s) (pure decideDriveAudience over the lead, accepted rows and, while enabled, the org's members) replaces the drive_members reads in getDriveRecipientUserIds, getDriveMemberUserIds(ByStandardRole|ByCustomRole), isMemberOfDrive and the calendar attendee union: a stale org row stops receiving drive broadcasts and mentions, an org Admin and an implicit Open member start. isUserMemberOfAnyEventDrive drops its dark drive_members branch for the same isUserDriveMember path (dark answer unchanged, pinned by B7b's frozen-legacy equivalence). checkDriveAccess, checkDriveAccessForRoles, getDriveAccess(WithDrive), resolveGranterAccess, resolveDriveMembership, the self-heal, the org sync and decideMoveDriveIntoOrg compare drives.ownerId only through isDriveLead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
Drive restore, permanent deletion, rename_drive, root-level page-agent creation, the 'cannot remove the drive owner' guard, the /api/drives ownership flag and storage/info's owner shortcut compared drives.ownerId inline; they ask the permissions layer's isDriveLead. Lead-only stays lead-only (an org Owner or Admin is not the lead). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
…rivacy kick, the all-backups list and restore_drive take members from the permissions layer (B7c) The candidate sets now come from getDriveRecipientUserIds (the org-aware audience) and are still decided per page by the canonical viewer checks; the page-privacy kick is 'members the viewer check no longer admits'. listAllUserBackups reads listMemberDrives (lead or ADMIN), and driveRepository.findByIdAndOwner asks isDriveLead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
…s, checked per query (B7c) drive-access-gates.seam.test.ts scans apps and packages for every drive_members query and ownerId comparison outside the permissions layer, attributes each to its enclosing function, and requires an exact per-function count with a reason (writer, invite management, payer lookup, own-drive inventory, display behind an access check, or an ownerId that is not a drive's). Self-tests plant each shape in fake files, including an ungated query beside an allowlisted one in the same function. The per-file enumeration and acceptedAt allowlists lose the 24 + 2 files that no longer read drive_members. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
… (B7c) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 66a57454fb
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const driveIds = (await listMemberDrives(userId, { includeTrashed: false })) | ||
| .filter((d) => d.isOwner || d.role === 'ADMIN') | ||
| .map((d) => d.driveId); |
There was a problem hiding this comment.
Use effective admin access when listing backups
When organizations are enabled, an org Owner/Admin without a direct membership row on a RESTRICTED or PRIVATE drive is deliberately omitted by listMemberDrives, even though isDriveOwnerOrAdmin grants them full administrative access and listDriveBackups allows the same user to view that drive's backups directly. Consequently, /api/backups silently omits backups the user administers; derive this list using the effective drive-admin permission rather than the picker/sidebar-oriented member-drive listing.
AGENTS.md reference: AGENTS.md:L119-L124
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in e4f102c. New getAdministeredDriveIds (permissions/member-drives.ts) returns the drives the user leads plus every drive whose effective membership is ADMIN. So an org Owner/Admin administers every drive of the org, joined or not, and org power over a PRIVATE drive writes the ORG-4 audit. Dark, it is owned + accepted ADMIN rows. It is checked on pg against getDriveAccess for every Northwind person and drive (getAdministeredDriveIds lists exactly the drives getDriveAccess makes the person lead or ADMIN of…, plus a dark case and a constant-query check). listAllUserBackups uses it (list-all-user-backups.test.ts 2/2). Leaving this open for verification.
| // Get pages updated count (pages in drives user has access to): the drives the user is a | ||
| // member of, owned or joined (org-aware; page-level access does not count) | ||
| const userDrives = (await getMemberDriveIds(userId, { includeTrashed: true })).map(driveId => ({ driveId })); |
There was a problem hiding this comment.
Filter activity counts through page permissions
For an ordinary org member implicitly included in an OPEN drive, getMemberDriveIds now adds that drive, but the following queries count every non-trashed page in it, including private pages and pages denied by a custom role. This both inflates the activity summary and reveals whether inaccessible pages changed; scope the counts through the canonical accessible-page permission set rather than drive membership alone.
AGENTS.md reference: AGENTS.md:L119-L124
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in e4f102c. The page counts are now over accessiblePageIds(userId), the canonical page set pulse already scopes to, so a private page or a page a custom role hides is never counted. Test: X-6 (partial) counts only pages the user can view (accessiblePageIds)… (summary route 5/5). Caveat, reported separately: the DB function behind accessiblePageIds (accessible_page_ids_for_user, migration 0133) is not org-aware yet. It is exact while ORGS_ENABLED is false, and it needs a migration lane. Leaving open.
2witstudios
left a comment
There was a problem hiding this comment.
[independent-review] agent:ow-irv-2689 — PR #2689 (B7c) at 66a5745
Verdict: 2 P1, 1 P2, 3 P3. Not mergeable on this head. P1-A is CI red, and the likely cause is in this PR. P1-B is the point-guard ruling (rename, trash and restore of an org-owned drive), which this head does not contain. I reviewed against the narrowed scope: transfer-lead is out and lives on the B8 leaf. Everything else I checked holds.
Per-ID verdicts
| ID | Claim | Verdict | Evidence |
|---|---|---|---|
| ORG-4 | ORG-4 (partial) ×15 |
partial, correctly marked; must stay partial | drive-gate-primitives.integration.test.ts › ORG-4 (partial) DRV-5 (partial) X-6 (partial) loadDriveRelationship(s) answer every person and drive exactly as getDriveAccess… and …listDriveAudiences names exactly the people getDriveAccess makes members…. Both pass locally on pg17, session TZ America/Chicago. Two clauses are unmet at this head: the ruling's lead-level actions for an org Owner/Admin (P1-B), and DM eligibility (still row-based). CI: ORG-4 allowlisted partial. |
| DRV-5 | DRV-5 (partial) ×22 |
partial, correct | The primitives matrix (implicit Open member with no row) and drive-audience-decision.test.ts. The sidebar and picker are Wave F. CI: DRV-5 allowlisted partial. |
| DRV-6 | DRV-6 (partial) ×1 |
partial, correct | …listMemberDrives lists exactly the drives a person is listed on and a member of…: an unjoined RESTRICTED/PRIVATE drive is never listed. The join request/approve flow is not here. CI: DRV-6 allowlisted partial. |
| DRV-7, DRV-9 | not claimed | n/a | The leaf lists them. Nothing in this PR proves them, and claiming nothing is correct. |
| X-6 | X-6 (partial) ×34 |
partial, correct | Stale source='org' rows, pending invitations and guests are refused in the primitives matrix (real pg). The policy, wallet and automation clauses belong to other lanes. See P3-2 on what the route-level cases actually prove. |
- Every ID in a test title is followed by exactly
(partial): one ASCII space, lowercase. The diff has no plain ID claim. scripts/spec-coverage-allowlist.txtis unchanged, and no ID is promoted.- CI Spec ID coverage (run 35563809109, job 106225236214):
- line 230:
DRV-5 allowlisted partial - line 231:
DRV-6 allowlisted partial - line 246:
ORG-4 allowlisted partial - line 309:
X-6 allowlisted partial - lines 311–312:
covered 10/87, allowlisted-missing 77, MISSING 0/spec-coverage: OK
- line 230:
(f) Can ORG-4 leave (partial)? No. At this head an org Owner/Admin is still refused restore, permanent delete and rename_drive on an org-owned drive (P1-B). DM eligibility is also still row-based. Even after P1-B lands, ORG-4 should leave (partial) only if a test proves every clause, including the audit event, on a PRIVATE drive for each action.
P1
P1-A — CI Unit Tests are red on this head, and the likely cause is the new integration suite.
- The failing step is lib
test:integration: Test Files 2 failed | 44 passed | 2 skipped (48).session-repository-resourceandreplay-across-replicasfail atbeforeAll:sorry, too many clients already(code 53300, 6 occurrences).
- The integration config runs every file in one fork (
singleFork: true).@pagespace/db/db's pool holds idle clients foridleTimeoutMillis: 600000, withmax: 10. - This PR adds
drive-gate-primitives.integration.test.tsto that run. It never callspool.end(), while its siblingorg-drive-sibling-resolvers.integration.test.ts:261does. - Base
d1f0c0d43was green (run 35559851834), with one integration file fewer. - I could not reproduce the exhaustion locally (my cluster has max_connections=300), so the causal link is inferred, not proven.
- Fix:
afterAll(() => pool.end())in the new suite, then re-run. If it stays red, bisect the file set. - E2E's single failure is
18-sidebar-directory-live.spec.ts:451, the known flake from Retro 0, so re-run it.
P1-B — the point-guard ruling (org Owner/Admin counts as lead for rename, trash and restore on an org-owned drive, each audited) is not in this head.
isDriveLead(drive-relationship.ts:36) is a pureownerId === userId.- These refuse an org Owner/Admin:
- restore (
restore/route.ts:52) - permanent delete (
trash/drives/[driveId]/route.ts:35) rename_drive(drive-tools.ts:287)
- restore (
isDriveLeadcannot answer the ruling as it stands: it needs the org role (IO) and must write an audit event, so it cannot stay a pure predicate onownerId.- I will re-review when the lane pushes. I will check:
- personal drives are byte-identical;
- each action writes exactly one audit event (on a PRIVATE drive, and whatever the ruling requires on OPEN/RESTRICTED);
- an org MEMBER is refused;
- a stale
source='org'row grants nothing; - the lane's mutation of the new branch goes red.
- Transfer-lead is out of scope per the ruling update (B8 leaf).
account/handle-driverefuses org drives atroute.ts:62, which I confirmed.
P2
P2-1 — the seam guard misses real comparison shapes, and one gate in the repo slips through today.
DRIVE_OWNER_COMPARE only matches .ownerId member access, eq/ne(drives.ownerId…) and quoted SQL. I planted each shape below in drives/[driveId]/trash/route.ts GET and ran drive-access-gates.seam.test.ts, then restored the file (git status clean):
| Planted shape | Guard |
|---|---|
const { ownerId } = drive; if (ownerId !== auth.userId) … |
GREEN 13/13 |
if (drive.ownerId == auth.userId) |
GREEN |
sql`${drives.ownerId} = ${auth.userId}` |
GREEN |
sql`select 1 from ${driveMembers} …` |
GREEN |
control: ungated db.select().from(driveMembers) beside the allowlisted one in drive-invite-repository.ts › findExistingMember |
RED, found {"reads":2…}, allowed {"reads":1…} |
- So CodeRabbit's per-query requirement holds for the shapes the scanner knows. The "finds every comparison with an ownerId" claim does not hold.
- Real miss:
apps/web/src/app/api/app-hosting/apps/[appId]/dedicated/route.ts:80-81.const ownerId = await lookupDriveOwnerId(app.driveId); if (!ownerId || ownerId !== auth.userId)is an inline drive-lead gate that is neither routed nor allowlisted. It is a payer gate, and under the ruling it is the kind of lead question that should go through the seam. - Fix:
- match a bare
ownerIdidentifier compared with===/!==/==/!=; - match
${drives.ownerId}and${driveMembers…}interpolated in SQL templates; - add self-tests for each shape;
- route
dedicatedthroughisDriveLead, or allowlist it with the PAYER reason.
- match a bare
P3 (body only, non-blocking)
- P3-1 —
listAccessibleDrives/listAccessibleDrivesWithOrgs(drive-service.ts) are allowlisted, but they are access decisions. The reason given is "the resolver itself". The leaf's rule allows only entries that are not access decisions. The behaviour is correct; this is a structural exception worth naming in the leaf, or a follow-up move intopermissions/. - P3-2 — the route-level "stale source=org row opens nothing" cases mock
loadDriveRelationshipto NONE. Example: trashroute.test.ts:245. They prove each route no longer reads the row itself (db.selectnot called), which is real wiring evidence. The stale-row refusal itself is proven only by the primitives integration matrix. The PR body's "15 routes/services" X-6 count should say this. - P3-3 —
activity/summarycounts pages updated acrossgetMemberDriveIdswith no per-page filter. It returns numbers only, and it was the same before. While enabled, the set now includes OPEN org drives, so private-page update counts there are visible as a number. Not new in kind.
What I verified
Throwaway homebrew postgresql@17 on 127.0.0.1:5591, session TZ America/Chicago, migrated from source. Source-alias harness, no dist builds.
-
(a) Sweep. I grepped every
ownerIdanddrive_membersmention outsidepermissions/. Apart from P2-1 and the allowlist, I found no unlisted gate. The allowlist reasons read correctly, except P3-1. -
(b) Old vs new, personal drives, ORGS_ENABLED false AND true.
-
I wrote a reviewer probe, a git-excluded pg integration file.
-
Fixture: randomized personal drives with OWNER/ADMIN/MEMBER rows, pending and accepted rows,
source='org'/invite, custom roles, trashed drives. 63 (user, drive) cells per mode; non-vacuous counts: member 25/21, admin 8/7, pending-ADMIN 3/3. -
I compared each primitive against the frozen inline code from
d1f0c0d43:Primitive Old inline gate Sites isDriveMemberRelationshipowner or accepted row page tree canAdministerDriveowner or accepted ADMIN trash, permissions-tree, drive pages admin branch, invite driveRoleOf ∈ {OWNER, ADMIN}owner or accepted OWNER/ADMIN bulk-copy, bulk-move batch loadDriveRelationshipsthe single-drive answer getMemberDriveIds(both trash flags) andlistMemberDrivesrolesowned ∪ accepted rows commands ×2, activity ×2, pulse ×3, memory discovery, threads, inbox, badges listDriveAudiences/getDriveRecipientUserIdsold getDriveRecipientUserIdschannel fan-out, calendar memberOfAnyDriveConditionold searchRelatedProfilesByNameEXISTSprofile search sharesMemberDriveold callerCanViewUserdrive clausevisibility -
4/4 passed: every answer identical in both modes.
-
page-reorder-serviceandpermission-management-serviceuse master'sisDriveOwnerOrAdminverbatim. The only change is a pending ADMIN losing admin, which is the listed dark difference.
-
-
(c) Traps.
- A pending ADMIN grants nothing: my probe (3 cells per mode) and Tomás in the matrix.
- A view-denying custom role grants no edit:
canAdministerDriveneeds role ADMIN, and page-level checks stay with the canonical resolvers. - An expired page share and a stale org row grant nothing: matrix.
- Cross-drive aggregates keep their per-page filters: commands (
canPrincipalViewPage), pulse ×3 (accessiblePageIds), threads, inbox and badges (getBatchPagePermissions). Discovery reads only the caller's own messages and activity.
-
(d) Constant queries (pool.query spy):
Primitive Dark Enabled loadDriveRelationships, 1 drive vs all1 / 1 1 / 1 listDriveAudiences, 1 drive vs all2 / 2 2 / 2 listMemberDrives, before vs after 15 extra memberships2 / 2 3 / 3 - The lane's own test (org drives: 3 = 3) also passes.
-
Tests run:
- lib integration
drive-gate-primitives8/8 andapp-shell-service10/10; - lib unit, 11 files 216/216: relationship, audience decision, acceptedAt gate, drive-member-service, calendar, invites, drive-repository, and the seams;
- web, 30 files 525 passed / 23 skipped: trash, pages/tree, invite, permissions-tree, drive pages, handle-drive, commands, activity summary, pulse, threads, inbox, badges, users/visibility, revoke-adapters, discovery, reorder, permission-management, backups, trash/drives, channel/command/activity tools, pages/[pageId];
- bulk-move 47/47.
- lib integration
-
Mutations by line index, anchor asserted, control first, restored, git status clean:
Line Mutation Result member-drives.ts:58drop isNotNull(driveMembers.acceptedAt)RED, 3 failed (control 8/8 GREEN) member-drives.ts:165stale-org-row clause → sql`true`RED, 1 failed (control 8/8 GREEN) bulk-move/route.ts:88role === 'OWNER' || role === 'ADMIN'→role !== nullRED, 1 failed (control 47/47 GREEN) -
Rules.
- No
any, and no env flag (ORGS_ENABLEDis the code constant). - No new
now()and no bulk inserts. The IN lists indrive-audience.tsare chunked at 500. - The new lib subpaths
drive-relationship,drive-relationship-loaderandmember-driveshaveexports+typesVersionsentries. Every@pagespace/lib/permissions/*import in apps resolves. - No real Spec ID appears in any seam self-test title.
- No
-
Scope. No migration. Nothing outside the leaf, except that the
dedicatedroute is untouched (P2-1).
| } | ||
| } | ||
|
|
||
| describe('B7c: the gate primitives agree with the canonical resolvers (integration)', () => { |
There was a problem hiding this comment.
P1-A. This suite joins lib test:integration, which runs every file in one fork (singleFork: true). It never ends @pagespace/db/db's pool, which has max 10 and idleTimeoutMillis 600000. Its sibling org-drive-sibling-resolvers.integration.test.ts:261 does call pool.end().
CI run 35563809109 Unit Tests shows later files failing at beforeAll with sorry, too many clients already (53300): session-repository-resource and replay-across-replicas. Base d1f0c0d was green. The causal link is inferred, not reproduced locally. Suggest afterAll(() => pool.end()) and a re-run.
There was a problem hiding this comment.
Follow-up in e405664. CI on e4f102c still ran out of connections, now in one file (replay-across-replicas). The real leak was mine: CI sets ADMIN_DATABASE_URL, and this file cleared the audit env only inside its lead-action describe. So the resolver matrix before it wrote ORG-4 audits through a dedicated admin pool that nothing ended. The env reset and pool.end now wrap the whole file, and app-shell-service.integration (which now runs Northwind) ends its pool too. Local, one fork, max_connections 100, ADMIN_DATABASE_URL set: drive-gate-primitives + app-shell + replay-across-replicas give 3 files, 25/25. Leaving open.
| const found = await db.query.drives.findFirst({ | ||
| where: eq(drives.id, driveId), | ||
| }); | ||
| const drive = found && (!requireUserOwnership || isDriveLead(auth.userId, found)) ? found : undefined; |
There was a problem hiding this comment.
P1-B (point-guard ruling). On an org-owned drive, an org Owner or Admin counts as lead for rename, trash and restore, and each use is audited. At this head, restore, permanent delete (trash/drives/[driveId]/route.ts:35) and rename_drive (drive-tools.ts:287) all still ask the pure isDriveLead, so an org Owner/Admin is refused.
In scope: an org-aware lead check with one audit event per use, and personal drives unchanged. Out of scope: transfer-lead (B8 leaf). I will re-review on the next push.
There was a problem hiding this comment.
Implemented in 50a26fd. Pure decideDriveLeadAuthority + IO loadDriveLeadAuthority: on an org-owned drive the lead and an org Owner/Admin may rename, restore and permanently delete, and each org-power use writes one authz.access.granted event {via, action, orgId, orgVisibility}. The settings rename and trash (owner-or-admin gate, where an org Admin is already ADMIN) and trash_drive record the same event via recordOrgPowerDriveAction. Personal drives stay owner-only; an org MEMBER or non-member is refused. pg: one audit row per action per drive across OPEN/RESTRICTED/PRIVATE. Mutation L1 (lead authority ignores org power) turns exactly the 10 org-Owner/Admin web cases RED, plus unit and integration. Transfer-lead is B8. Leaving open.
| * column (`ownerId: drives.ownerId`) and writing it (`.set({ ownerId })`) are not comparisons. | ||
| * Non-drive owners (agent workspaces, sessions, organizations) match too and are allowlisted as such. | ||
| */ | ||
| const DRIVE_OWNER_COMPARE = [ |
There was a problem hiding this comment.
P2-1. These patterns miss real comparison shapes. Planted in drives/[driveId]/trash/route.ts GET, each of these left the guard GREEN (13/13):
const { ownerId } = drive; if (ownerId !== auth.userId)drive.ownerId == auth.userIdsql`${drives.ownerId} = ${auth.userId}`sql`select 1 from ${driveMembers} …`
A real instance ships today: apps/web/src/app/api/app-hosting/apps/[appId]/dedicated/route.ts:81, if (!ownerId || ownerId !== auth.userId) on lookupDriveOwnerId. It is neither routed nor allowlisted.
Suggest matching bare *ownerId identifiers under ===/!==/==/!= and ${drives.ownerId} / ${driveMembers…} interpolations, with a self-test for each shape. Then route or allowlist dedicated with the PAYER reason.
There was a problem hiding this comment.
Fixed in e4f102c. access-gate-scan now matches any *ownerId under ===/!==/==/!= (destructured ownerId, driveOwnerId), ${drives.ownerId}, and ${driveMembers} in sql templates, with a self-test for each. Your four planted shapes, re-planted in trash/route.ts GET, each go RED (1 failed | 13 passed; file restored). The 15 newly found functions are allowlisted with reasons: dedicated as the payer gate, plus non-drive owners (agent sessions and streams, auth sessions, orgs, workspaces, dev preview, env binding) and the client usePermissions flag. Leaving open.
…lead actions, each audited (B7c, point-guard ruling) decideDriveLeadAuthority / loadDriveLeadAuthority: rename (rename_drive), restore (web route, restore_drive via driveRepository.findByIdForLeadAction) and permanent delete are open to the drive's lead and, on an org-owned drive, to an org Owner or Admin; each org-power action writes one authz.access.granted event (action, org power, visibility). Rename and trash through the drive settings route and trash_drive, which already admit an org Admin as ADMIN, record the same event (recordOrgPowerDriveAction). Personal drives stay owner-only; an org MEMBER and a non-member are refused; dark, org power is nothing. The drive-gate-primitives integration file now ends its pool (CI ran out of connections). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
…nts only viewable pages; the seam guard sees every ownerId comparison shape (B7c review)
- getAdministeredDriveIds (lead + effective ADMIN, org-aware: an org Owner/Admin
administers every drive of the org, joined or not, with the ORG-4 private-drive audit);
listAllUserBackups uses it (Codex P2).
- activity/summary counts pages updated over accessiblePageIds, the page set pulse
scopes to, instead of every page of the member drives (Codex P2, review P3-3).
- access-gate-scan matches any *ownerId under ===, !==, ==, != (a destructured ownerId,
driveOwnerId), ${drives.ownerId} and ${driveMembers} in sql templates, with a self-test
each; the 15 functions it newly finds are non-drive owners, a client display flag and
the app-hosting payer gate, allowlisted with reasons (review P2-1).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
…pen (B7c review P1-A) drive-gate-primitives cleared the audit env only inside its lead-action describe, so its resolver matrix wrote ORG-4 audits through a dedicated admin pool (CI sets ADMIN_DATABASE_URL) that nothing ended; the env reset and pool.end now wrap the file. app-shell-service, which now runs the Northwind fixture, ends its pool too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF
B7c: no inline drive access gates outside the permissions layer
Leaf
kxgdqsjqyrlyvcytnhlbdllz(Phase 1 taskjcz0vxkkjwvsmnqqhqi6sb5a). Basepu/org-walletsatd1f0c0d43(#2673, B7b); merge-base equals that tip. Wave F waits on this PR.What changed
1. Four permission primitives (
packages/lib/src/permissions). Every former inline gate now calls one of them.drive-relationship.tsholds the pure decisions:isDriveLead,driveRoleOf,canAdministerDriveandisDriveMemberRelationship.RelationshipDriverequiresid,ownerId,orgIdandorgVisibility. A caller that selects only{ id, ownerId }would otherwise be read as a personal drive (review 5237996587, P3-4).drive-relationship-loader.tsdoes the IO:loadDriveRelationship(s)answer "lead, plus the effective membership from B7b's shared loader".member-drives.tsholdslistMemberDrives,getMemberDriveIds,sharesMemberDriveandmemberOfAnyDriveCondition. They answer "which drives is this person a member of":decideListedDriveRolewithout page shares. That means owned drives, valid rows and OPEN drives of the person's org. It never includes a stale org row, a pending invitation, a page share, or an unjoined RESTRICTED/PRIVATE drive (DRV-6).memberOfAnyDriveConditionis the same rule as a SQL condition, so the profile search can filter inside the database. The integration suite checks it against the decision.drive-audience.ts(with puredecideDriveAudienceinorg-drive-resolution.ts) holdslistDriveAudience(s), the one member enumeration.resolveEffectiveDriveMembershipadmits, each with an effective role and custom role.2. Routed gates. Each one kept its own role test on the answer, so a personal drive answers as before. The exceptions are listed under "Dark-visible differences".
drives/[driveId]/trash,permissions-tree,drives/[driveId]/pages(admin branch)ownerId ===+ ADMIN rowcanAdministerDrive(loadDriveRelationship)drives/[driveId]/pages(non-admin branch)getUserAccessiblePagesInDrivepages/treeisDriveMemberRelationshippages/bulk-copy,pages/bulk-movedriveRoleOf(...) ∈ {OWNER, ADMIN}page-reorder-service,permission-management-serviceisDriveOwnerOrAdmin) + org casesdrives/[driveId]/members/invite(findAdminMembershipremoved)canAdministerDriverevoke-adapters, libinvitesport)findActorDriveRoleport →driveRoleOfaccount/handle-driveownerId !==+ ADMIN row of targetisDriveLead, targetmembership.role === 'ADMIN'commands+command-toolsgetMemberDriveIds,activity/summary,activity-tools,pulse×3, memorydiscovery-servicegetMemberDriveIdsmessages/threads,inbox×2,sidebar/badges(channel candidates)JOIN drive_members+d."ownerId" =p."driveId" = ANY(getMemberDriveIds); threads also gains thegetBatchPagePermissionsfilterlib/users/visibility(callerCanViewUser, profile search)sharesMemberDrive,memberOfAnyDriveConditionapp-shell-servicelistMemberDrives(in the shell's transaction)ai/page-agents/multi-drive(sandbox edit rule)loadDriveRelationships+membershipRowsOfgetDriveRecipientUserIds,getDriveMemberUserIds(ByStandardRole|ByCustomRole),isMemberOfDrive, calendargetAllMemberUserIdsForEventlistDriveAudience(s)channel-toolsinbox notifications, page-privacy kickgetDriveRecipientUserIds, then the existing canonical per-page checklistAllUserBackups,driveRepository.findByIdAndOwnereq(ownerId)listMemberDrives(lead or ADMIN) /isDriveLeadrename_drive, root page-agent create, "cannot remove the owner",/api/drivesownership flag,storage/infoshortcutownerIdisDriveLead(lead-only stays lead-only)checkDriveAccess,checkDriveAccessForRoles,getDriveAccess(WithDrive),resolveGranterAccess,resolveDriveMembership, self-heal, org sync,decideMoveDriveIntoOrgdrive.ownerId === userIdisDriveLeadisUserMemberOfAnyEventDriveisUserDriveMember(dark answer unchanged; pinned by B7b's frozen-legacy equivalence test)3. Seam guard, validated per query (
packages/lib/src/__tests__/seams/drive-access-gates.seam.test.ts+access-gate-scan.ts).apps/andpackages/source file outsidepackages/lib/src/permissions(andpackages/db,apps/e2e). It finds everydrive_membersquery and every comparison with anownerId:.from, joins,query.driveMembers.find*, raw SQL;eq/neondrives.ownerId, JS===/!==, and SQL"ownerId" =.listAccessibleDrives(WithOrgs), decided bydecideListedDriveRole);ownerIds that are not a drive's (agent sessions, conversations, env enrollments, webhook secrets).drives/[driveId]/trash/route.tsGET (if (drive.ownerId === auth.userId)) gavetrash/route.ts › GET: found {"reads":0,"ownerCompares":1}, allowed {…0}. The file was then restored andgit statuswas clean.drive-members-enumerationallowlist loses 24 files, and the webdrive-member-gate-coverageallowlist loses 2. Both now readdrive_membersthrough the seam.Point-guard ruling: lead actions on org-owned drives (
50a26fd86)On an org-owned drive, the drive lead and an org Owner or Admin may rename, trash and restore it (ORG-4 full access; DRV-1: the human lead keeps the Owner role). Each org-power use writes an audit event. Personal drives stay owner-only, and an org MEMBER or a non-member is refused. Transfer-lead is not in this PR: no change-drive-lead action exists anywhere, and it belongs to the B8 leaf (
dl27rujimf5clm6nwji6tw1u, section "Change a drive's lead").The decision. Pure
decideDriveLeadAuthority(drive-relationship.ts) returns lead / org-owner / org-admin / refused.isDriveLeadstays the plaindrives.ownerIdfact: it also drives the ownership flags, the self-heal row, the audience's lead and the org sync, where widening it would be wrong.The IO.
loadDriveLeadAuthority(drive-relationship-loader.ts) reads the org role only for an org drive the user does not lead, and only while enabled. When org power is what allows the action, it writes oneauthz.access.grantedevent with{ via: org_owner|org_admin, action, orgId, orgVisibility }, on every visibility.Surfaces:
rename_drivetoolloadDriveLeadAuthorityrecordOrgPowerDriveActiontrash_drivetoolrecordOrgPowerDriveActionrestore_drivetoolloadDriveLeadAuthority; the tool goes throughdriveRepository.findByIdForLeadAction(replacesfindByIdAndOwner)trash/drives/[driveId]loadDriveLeadAuthorityTests.
securityAudit.queryEvents, and none for the lead or a refusal. A dark case too.rename_drive, settings rename, settings trash,trash_drive/restore_drive.Mutation:
drive-relationship.ts:76: lead authority ignores org powerdrive-relationship-loader.ts:110: no audit writedrives/[driveId]/route.ts:361: trash audit droppedrestore/route.ts:53: back toownerId ===Review round 1 (
50a26fd86,e4f102c67)P1-A (CI Unit Tests:
too many clients already). The new integration file now ends its pool inafterAll(50a26fd86). E2E's only failure was18-sidebar-directory-live.spec.ts:451, the known Retro 0 flake.P1-B (ruling). See above.
Codex P2 (backups).
getAdministeredDriveIds: lead + effective ADMIN, so an org Owner/Admin administers every drive of the org, joined or not. It uses the ORG-4 private-drive audit, and a dark case gives owned + accepted ADMIN rows.getDriveAccessfor every person and drive, plus a constant-query check.listAllUserBackupsuses it.Codex P2 / review P3-3 (activity summary). Counts are over
accessiblePageIds(the page set pulse scopes to), not every page of the member drives. A private page, or a page a custom role hides, is no longer counted (a dark-visible difference, in the right direction).Review P2-1 (guard shapes).
access-gate-scannow matches any*ownerIdunder===/!==/==/!=(a destructuredownerId,driveOwnerId), and${drives.ownerId}/${driveMembers}in sql templates, with a self-test for each.usePermissionsflag, andapp-hosting/.../dedicated(payer gate). All are allowlisted with reasons, which brings the allowlist to 68 functions in 51 files.Review P3-2. The route-level "stale source=org row" cases prove each route asks the relationship and reads no row itself. The stale-row refusal itself is proven by the primitives matrix on Postgres.
Review P3-1.
listAccessibleDrives(WithOrgs)is an access resolver that lives inservices/, allowlisted as "the resolver itself". Moving it intopermissions/is a follow-up, not this leaf.Found while fixing, not fixed here (needs a migration; this lane owns none). The DB function
accessible_page_ids_for_user(migration 0133), behindaccessiblePageIds(pulse, activity summary), is not org-aware:source='org'row included;It is exact while dark, and it is flagged in #org-wallets.
Requirement IDs and tests
ORG-4 (partial) DRV-5 (partial) X-6 (partial) loadDriveRelationship(s) answer every person and drive exactly as getDriveAccess…·ORG-4 (partial) DRV-5 (partial) X-6 (partial) listDriveAudiences names exactly the people getDriveAccess makes members of each drive…(drive-gate-primitives.integration) · route/service cases: trash, permissions-tree, drive pages, bulk-copy, bulk-move, invite, reorder, permission-management, revoke adapter, backups list, multi-drivemembershipRowsOf,getDriveRecipientUserIds, calendar attendeesdrive-audience-decision· app-shell integration · every "implicit Open member" route case (drive pages, page tree, commands ×2, activity ×2, pulse, discovery, threads, inbox, badges, visibility, channel-tools, page-privacy kick)DRV-5 (partial) DRV-6 (partial) X-6 (partial) listMemberDrives lists exactly the drives a person is listed on and a member of…drive-audience-decisionORG-4 still cannot leave
(partial), even with the ruling in. With this PR every apps/web gate agrees with the resolvers: org Owner/Admin power opens the drive, and a PRIVATE-drive access is audited throughloadEffectiveDriveMembership. The two clauses still missing are:accessible_page_ids_for_useris row-based (above);users/messageable,usersShareDrive) is still row-based and deliberately ungated, so it is allowlisted.Reviewer: please confirm ORG-4 stays
(partial).scripts/spec-coverage-allowlist.txtis unchanged.Dark-visible differences (ORGS_ENABLED false)
Personal drives answer exactly as before except for these. Each one brings a gate into agreement with the canonical resolver.
messages/threadslists only channelsgetBatchPagePermissionslets the caller view. Before, a pending invitee, a private channel or a channel hidden by a custom role listed its title and last message. Master already has this (fix(permissions): pending drive invites grant nothing; a custom role's drive-wide grant never opens a private page #2672).page-reorder-service/permission-management-service: a pending ADMIN invitation is no longer admin. These are master's fix(permissions): pending drive invites grant nothing; a custom role's drive-wide grant never opens a private page #2672 lines verbatim.drives/[driveId]/pages, non-admin branch: a custom role's limits and an expired page share now apply. They matchgetUserAccessLevel, and the ancestor walk starts from non-trashed pages only, as before.pulse×3 and memory discovery now include owned drives that lack an OWNER self-heal row (again, org leads never get one).getUsersWhoCanViewPageover the drive's members. A non-lead holding a personal drive's OWNER row is kicked if they can no longer view the page. Everyone else is kicked exactly as before.getDriveMemberUserIdsandisMemberOfDrivenow include the lead. Neither has a production caller.getBatchPagePermissionsfilter is unchanged, so the output is identical.Traps checked
listMemberDrivesrows;loadAcceptedRowsInDrives;listDriveAudiences, pinned inacceptedAt-gate.test.ts;memberOfAnyDriveCondition.getUserAccessiblePagesInDrive. Implicit members carry the default role everywhere (Handbook's view-denying default in the matrix).listMemberDrivesignores shares entirely (Lu's expired share lists nothing).getBatchPagePermissions, and the channel fan-outgetUsersWhoCanViewPage. The candidate drive sets only feed them.pool.query:loadDriveRelationships: 1 drive = all drives = 3;listMemberDrives: unchanged after adding 12 OPEN drives;listDriveAudiences: 1 drive = all.ai/page-agents/multi-driveloopsgetPrincipalDriveAccessover every drive in the system.Mutation checks
Method: by line index at
66a57454f, with an anchor asserted on the line. Each distinct command ran once unmutated as a control (16 controls, all GREEN).git statuswas clean after.member-drives.ts:63if (!ORGS_ENABLED)→if (true)member-drives.ts:164dropne(driveMembers.role, 'OWNER')in the SQL conditiondrive-relationship.ts:47canAdministerDriveadmits any membershipdrive-relationship-loader.ts:35batch ignores rowsorg-drive-resolution.ts:250audience ignores org membersdrive-audience.ts:42acceptedAt gate removedtrash/route.ts:55owner-onlypages/tree/route.ts:66any relationship passesmessages/threads/route.ts:207permission filter droppedinbox/route.ts:50empty member-drive setrevoke-adapters.ts:39always ADMINvisibility.ts:46always sharescalendar-event-drive-service.ts:314home drive onlydrive-backup-service.ts:84no lead/ADMIN filterpages/[pageId]/route.ts:164nobody kickedapp-shell-service.ts:166everything ownedaccess-gate-scan.ts:186presence-only (per-file-style) checkhandle-drive/route.ts:82any membership may receive ownershipdrive-member-service.ts:126recipients = lead onlyCommands and results
Local only; the build slot was respected and CI is the gate.
Test Postgres: a throwaway homebrew postgresql@17 on 127.0.0.1:5577 with session TZ America/Chicago, migrated with
bun run --filter @pagespace/db db:migrate.Harness: the worktree source harness (
vitest.wt*.config.ts,tsconfig.wt.json) is git-excluded.Lib integration:
DATABASE_URL=postgresql://user@127.0.0.1:5577/pagespace_test bunx vitest run --config vitest.wt-int.config.ts <file>, 10 suites, 116 passed:drive-gate-primitives.integration.test.tsorg-drive-sibling-resolvers(B7b matrix + dark equivalence)org-drive-resolverspage-viewersapp-shell-serviceorg-membership-syncleaveorg-servicesorg-member-revocationorg-drive-serviceLib unit:
bunx vitest run --config vitest.wt.config.ts src/permissions src/services src/organizations src/__tests__ src/agent-workspaces src/repositories src/auth --exclude '**/*.integration.test.ts'→ 306 files, 7650 passed | 1 todo.Web, every directory holding a touched file:
bunx vitest run --config vitest.wt.config.ts <32 dirs>→ 583 files, 9459 passed, 120 failed in 57 files.origin/pu/org-walletscheckout run the same way. They are source-harness partial-mock artifacts, and none is new.bulk-copy,bulk-moveandmanage-keys-scopeare among the files that cannot collect under the harness. Their copies with the db mocks spread over the real modules pass: 40/40, 47/47, 1/1.Types:
bunx tsc --noEmit -p tsconfig.json(packages/lib): 0 errors.bunx tsc --noEmit -p tsconfig.wt.json(apps/web): the one error is a harness-missingprosemirror-modelin untouchedpackages/editor. An injected probe error invisibility.tswas reported, so the check is live.Lint:
bunx eslinton all 71 touched web files: 0 problems. The touched lib sources give 0 errors; lib test files are outside the lint config.Left to CI: monorepo typecheck, knip, the full web/realtime suites, E2E, spec coverage.
drive-gate-primitives.integration.test.tsis added to libvitest.config.ts's excludes (unit run and coverage), so it runs in thetest:integrationstep.New lib subpaths
permissions/drive-relationship,drive-relationship-loaderandmember-driveshaveexports+typesVersionsentries.Not in this PR / for the reviewer
users/messageable, libusersShareDrive) is deliberately ungated and row-based. It is unchanged and allowlisted. An implicit Open member cannot DM co-members through it, and a stale org row still can. This needs its own ruling.assigneespicker and the pulse team roster enumerate rows for display (allowlisted). Once the B4 sync materializes rows, implicit members appear there.drive-member-gate-coverage.test.ts,messages/threads/route.tsandinbox/route.tsdiffer between master andpu/org-wallets, so the integration merge must keep this branch's candidate SQL and master's per-query scanner.ORGS_ENABLED=false. The dark-visible fixes above are the fix(permissions): pending drive invites grant nothing; a custom role's drive-wide grant never opens a private page #2672 master parity items plus the lead-without-OWNER-row items.🤖 Generated with Claude Code
https://claude.ai/code/session_016yxfMSwCxuwewgy3rnkrdF