#262 PR2 (collection enforcement) — handoff¶
Resume point for a fresh session. The canonical design is
plan-permissions.md (merged in PR 1). This file is the
PR-2-specific state + what remains.
Where things stand¶
- PR 1 (#282) — MERGED. The
perm/package:Permissionvalue object +Actor+ centralauthorize(actor, verb, permission, *, created_by, superusers). Pure model, fully tested. Readsrc/workspace_app/perm/first. - PR 2 — in progress on branch
worktree-issue-262-collection-enforce(worktree.claude/worktrees/issue-262-collection-enforce). 3 commits; 2308 unit tests pass, zero regressions. Not yet PR'd / merged.
The hard part is solved: enforce via specstar access_scope (≥ 0.11.11)¶
A spike proved specstar's before permission-checker and on_success event
handlers cannot enforce the auto-CRUD HTTP routes (before-get has no resource
data; the envelope GET _handle_get_with_returns bypasses event-emitting reads).
So I filed specstar #398; the author shipped it as #399/#400/#401,
published v0.11.11. The mechanism is now:
access_scope(read/list visibility + write precondition) —add_model(M, access_scope=lambda user: ConditionBuilder | None | UNRESTRICTED). specstar ANDs it into every read (all GET variants + list/search/count) and gates every write that targets an existing row, at the storage layer. Out of scope → 404 (before the checker; no existence leak). InternalResourceManagercalls are unscoped; custom routes opt in withrm.using(user, apply_access_scope=True).UNRESTRICTED= see-all (superuser).permission_checker(per-verb authorization → 403) — runs only for in-scope rows. With #399/#401 the write/lifecycle contexts now carrycurrent_resource(data + meta).- They compose:
access_scope= "does this row exist for me?" (404);permission_checker= "may I do this action?" (403). Docs:specstar/docs/en/howto/access-scope.md.
What's DONE on the branch¶
Collection.permission: Permission | None = None(resources/kb.py) —None≡ public (no migration).perm/scope.py::collection_access_scope(superusers)— the visibility predicate mirroringauthorize(read_meta):permission.visibility IS NULL | == 'public' | created_by == user | (== 'restricted' & permission.read_meta contains_any [user:<id>, all]); superuser →UNRESTRICTED.- Registered in
resources/__init__.py:add_model(Collection, indexed_fields=[("permission.visibility", str), ("permission.read_meta", list)], access_scope=collection_access_scope(superusers)).make_spec(..., superusers=frozenset())threads the set. kb_routes.list_collectionsfilters the hand-written/kb/collectionslist in Python viaauthorize(read_meta)(_can_read_meta). (Could instead opt the aggregate intoapply_access_scope; Python filter is fine + explicit.)- Tests
tests/api/test_collection_perm.py: private hidden from list; auto-CRUDGET /collection/{id}→ 404 for non-owner; superuser sees all.
What's now DONE (items 1, 2, 3, 5 — completed this session)¶
- Permission-set endpoint
PUT /kb/collections/{id}/permission— body = visibility + grant lists (full replace); gated withauthorize(..., "change_permission", ...)(404 if you can'tread_metait, 403 if you can't change it); persists as the owner (rm.using(created_by)) so the write checker'swrite_metagate doesn't block achange_permission-only delegate; emits aNotification(kind="share")to newly-granted users.kb_routes.py. - Per-verb write checker —
perm/checker.py::CollectionPermissionChecker.update/modify/patch→write_meta(apermissionchange additionally needschange_permission);delete/permanently_delete/switch/restore→ owner/superuser only. The FE edits via PATCH and deletes viaDELETE …/permanently— both now gated. - specstar 0.11.11 gotcha (important):
add_model(permission_checker=…)is SILENTLY SHADOWED —ResourceManageris built withself.permission_checker or permission_checkerand the spec default is a truthyAllowAll(), so the per-model checker never runs (onlyaccess_scopeis threaded straight through). We attach the checker via the per-modelevent_handlersslot instead (wrapping it inPermissionEventHandler). One consequence: it fires on EVERYResourceManagerwrite, not just request-routes — the lone programmatic Collection write (code_reposync's git-metadata stamp) now writes as the owner to passwrite_meta. - We do NOT use
ActionBasedPermissionChecker(itsnot_applicablefor unmapped actions is treated as a denial → would 403 reads/creates); the custom checker returnsallowfor everything outside the gated verbs. - Content-route guards (
kb_routes.py::_authorize_collection):POST .../documents+.../import→add_content;sync/reindex→edit_content;PUT .../wiki/page→edit_content.read_meta-first (404, no existence leak) then the verb (403). superuserswiring:ServerSettings.superusers→factories.get_spec(make_spec(superusers=…))ANDcreate_app(superusers=…)→register_kb_routes(route-levelauthorize). Documented inconfig.example.yaml. Prod has noconfig.yaml(seeai-workspace-prod-deploymentmemory) so the set is empty until configured.
What REMAINS (deferred to a follow-up)¶
- SourceDoc access inheritance (deferred — design-uncertain, the handoff
flagged it). A SourceDoc's access should = its parent collection's
Permission(no per-doc perms in v1). Residual gap until done: the auto-CRUDGET /source-doc/{id}and the hand-written doc READ routes (GET /kb/documents,list_documents, chunks, export/download) do NOT yet inherit the collection's visibility — a non-member who knows/guesses a doc id could read a restricted collection's document. Collection-level visibility + all WRITE/content-mutation paths ARE enforced. Likely fix: a SourceDocaccess_scopejoining oncollection_id, or guard the doc read routes the same way_authorize_collectionguards the mutation routes (verb =read_content).
Out of scope (later PRs / issues)¶
App-item enforcement (PR), KbChat migration to Permission (PR), background
workers + use_terminal, the owner tightening UI (PR 6), a first-class
logical Group entity + governance UI, per-doc perms. The ask_knowledge_base
/ KB-chat collection_ids transitive checks (B-2 in plan-permissions.md) ride
the same authorize(read_content) against the speaker.
Resume¶
cd into the worktree (or git checkout worktree-issue-262-collection-enforce),
uv sync --all-extras, then uv run pytest tests/api/test_collection_perm.py.
Related: #242 (merged, speaker identity), #275 (lookup_user follow-up), specstar