Skip to content

feat: skill permission levels (read / read-write) + ownership transfer (#1123)#1124

Merged
chronoai-shining merged 17 commits into
developfrom
feature/1123-skill-permission-levels
Jun 15, 2026
Merged

feat: skill permission levels (read / read-write) + ownership transfer (#1123)#1124
chronoai-shining merged 17 commits into
developfrom
feature/1123-skill-permission-levels

Conversation

@chronoai-shining

Copy link
Copy Markdown
Collaborator

Summary

Adds skill & skillset permission levels (read / read-write) and owner-to-owner ownership transfer, end to end: API + both SDKs + web UI + docs.

Closes #1123

What changed

Three-tier object-permission model (skills + skillsets), one source of truth in authorize.ts:

Tier Gate Who Can do
READ canReadSkill public · any grantee · granted-org member view · pull · execute · list versions
READ_WRITE canWriteSkill (new) author · platform admin · read_write grantee + update content / metadata
ADMIN canManageSkill owner + platform admin only + permissions · transfer · delete · deprecate · dist-tags · service-bind
  • Typed grants ACL { type, id, level } is the canonical sharing primitive across DB, API, and both SDKs. Legacy sharedWithUsers/sharedWithOrgs payloads are still accepted (→ read-level) for back-compat.
  • Ownership transferPOST /skills(/sets)/:id/transfer-ownership { newOwnerUserId }. Immediate; ADMIN-gated; target validated against the user directory (invalid_transfer_target / ownership_conflict); prior owner kept as a read grantee; platform admins can force-transfer.
  • Web — "Transfer ownership" in the skill Danger Zone (directory picker + type-the-name confirm); per-grant read/read-write toggles in the permissions editor; "N can edit" on the visibility card.
  • SDKs — TS + Python gain grants, a skill permissions setter (neither had one), and the transfer methods.
  • Docs — CONVENTIONS (reconciled the real route scopes + new object-tier matrix + transfer endpoints), ERRORS (3 new codes), ARCHITECTURE (analytics event + payload).

Non-disruption (load-bearing)

No existing skill loses access or is escalated to write. Every existing public / org / per-user share migrates to read-only.

  • Idempotent boot migration folds the legacy lists into read-level grants; never emits read_write, never touches isPrivate, never removes the legacy arrays.
  • Repository dual-writes the legacy lists from grants, so a rolling deploy and any un-migrated doc stay correct; effectiveGrants provides a read-time fallback.
  • For pre-existing data, canWriteSkill ≡ the old canManageSkill (no read_write grants exist), so write/admin behavior is unchanged until an owner opts in.
  • Rollback-safe: grants is purely additive; the old image ignores it and reads the untouched legacy lists.

Deliberate scope call

scopeFilter / grants-summary Mongo queries were not rewritten — the dual-written legacy lists remain an exact read-visibility index, so they're correct unchanged (this removes the riskiest read-path change). The query rewrite + dropping the legacy fields is a follow-up tied to full rollout (tracked in #1123's checklist).

Verification

  • Backend 1919 · Frontend 562 · TS SDK 38 · Python SDK 45 — all pass.
  • typecheck (api/web/sdk) clean; lint 0 errors.
  • Migration integration test pins the non-disruption invariant (read-only preserved, nothing escalated, idempotent, read_write never clobbered).

🤖 Generated with Claude Code

Introduce the canonical typed access-grant shape that will replace the
legacy read-only `sharedWithUsers` / `sharedWithOrgs` allow-lists: a
`SkillGrant { type, id, level }` pairing a user/org principal with a
`read` or `read_write` level, plus a pure, reusable `grants.ts` helper
module (effective-grant resolution, legacy derive/project, normalize).

The field is added to `SkillDocument` as OPTIONAL on purpose. Skills
predating #1123 carry only the legacy lists, and `effectiveGrants`
derives identical READ-level grants from them when `grants` is absent —
so this commit changes no behaviour and cannot disrupt existing skills.
`legacyListsFromGrants` is the inverse projection used later for the
transitional dual-write that keeps a rolling deploy non-disruptive.

Pure machinery, no wiring yet (authz/scopeFilter/routes follow). Unit
tests pin the grant algebra, especially the legacy round-trip and the
read_write-wins normalization.

Part of #1123
Wire the `grants` field through the skills repository and the detail
response:

- `create` seeds an empty `grants` array (new skills are born migrated)
  and keeps the legacy lists in lock-step.
- `update` treats `grants` as the canonical ACL write: when provided it
  dual-writes the legacy `sharedWithUsers` / `sharedWithOrgs` lists via
  `legacyListsFromGrants` (every grant id → read visibility) so an older
  pod mid-rolling-deploy still resolves correct read access and nobody is
  escalated to write. Legacy `sharedWith*` writes only apply when `grants`
  is absent.
- `mapDoc` surfaces stored grants with defensive coercion (drops malformed
  entries) and leaves the field undefined for un-migrated docs.
- the skill detail response now carries `grants`, emitted via
  `effectiveGrants` so the shape is identical whether or not the skill has
  been migrated.

No authorization behaviour changes yet — `grants` is only read back, the
gates still use the legacy lists. Existing CRUD suite (342 tests) stays
green.

Part of #1123
Mirror the skills grant-persistence onto the skillsets domain, which
shares the ownership model verbatim:

- `SkillsetDocument` + `SkillsetDetailResponse` gain the optional `grants`
  field; the detail builder emits `effectiveGrants(skillset)`.
- the skillset repository create/update/mapDoc handle `grants` with the
  same canonical-write + transitional dual-write of the legacy lists.

Promote the defensive stored-grant coercion into the shared `grants.ts`
as `coerceStoredGrants` and use it from both repositories, removing the
duplicate that briefly lived in the skills repo. Unit tests cover the
malformed-entry drops and the un-migrated/empty cases.

Still no authz behaviour change — grants are only read back.

Part of #1123
Backfill the typed `grants` array on every skill / skillset that predates
it, deriving one `read`-level grant per legacy `sharedWithUsers` /
`sharedWithOrgs` entry. Runs at boot right after the model-catalog
migration, before any skill read is served.

Non-disruptive by construction — the hard requirement for this feature:
the legacy lists are left untouched, public/private flags are untouched,
and nobody is escalated to write (every backfilled grant is `read`).
Idempotent: only docs missing `grants` are matched, so reruns are no-ops;
it runs server-side as one updateMany + aggregation pipeline per
collection. Failure is logged non-fatally because `effectiveGrants` still
falls back to the legacy lists for any un-migrated doc.

Integration tests run the migration against a real Mongo and pin the
non-disruption invariant, the empty/ancient-doc cases, idempotency, and
that an existing read_write grant is never clobbered.

Part of #1123
Make the single source-of-truth `authorize.ts` (shared by skills +
skillsets) grant-aware:

- `canReadSkill` now resolves access from the typed `grants` ACL — any
  level confers read, directly or via membership of a granted org. It
  falls back to `effectiveGrants` so an un-migrated doc reads exactly as
  before off its legacy lists.
- new `canWriteSkill` = author OR platform admin OR a `read_write` grant
  (direct or via a granted org) — the READ_WRITE tier (content + metadata
  edits only).
- `canManageSkill` is unchanged (author OR platform admin) = the ADMIN
  tier; a read_write grantee is deliberately never an admin.

A shared `actorMatchesGrant` core walks the effective grants once so the
read and write gates can never diverge on the matching rules; both fail
soft on an unresolved org-membership lookup, matching the existing read
behaviour. `SkillOwnership` gains the optional `grants` field.

No route yet calls `canWriteSkill` — that re-pointing lands next. Full
skills + skillsets suites (758 tests) stay green.

Part of #1123
Re-point the content/metadata update paths at the new write gate so a
read_write grantee can update a skill without being its owner:

- `PUT /skills/:id`: base gate becomes `canWriteSkill`; a request that
  actually changes `isPrivate` additionally requires `canManageSkill`, so
  flipping visibility stays an ADMIN-tier action (a read_write grantee can
  replace content but not toggle public/private).
- skillset `publishVersion` gates on `canWriteSkill` (publishing a new
  version is a content edit); permissions/transfer/delete stay ADMIN-only.
- add `canWriteSkillset` to the skillset authorize delegation + `grants`
  to `SkillsetOwnership`.

Route tests cover the new matrix: read_write grantee republishes content
(200), read_write grantee flipping visibility (403), read-only grantee
republishing content (403).

Part of #1123
Extend `PUT /skills/:id/permissions` and `PUT /skillsets/:id/permissions`
to carry the canonical typed `grants` ACL, while still accepting the
legacy `sharedWithUsers` / `sharedWithOrgs` arrays for backward-compatible
callers (they map to READ-level grants).

- add the shared `skillGrantSchema` + a `resolvePermissionGrants` helper to
  `grants.ts` (prefers typed grants, falls back to the legacy lists).
- both permission setters now resolve to normalized grants, drop a grant
  that names the author (implicit ADMIN), and reuse the existing #815/#842
  org-membership gate over the resolved org grants — so sharing a
  read_write grant into a non-member org is rejected exactly like a read
  share, and an unresolved lookup still 503s.
- `skill.permissions_changed` analytics gains a `readWriteGrants` count.

The service methods are the back-compat boundary, so the existing
org-gate tests pass unchanged; assertions that checked the dual-written
legacy lists now assert on the canonical `grants`. New tests cover the
typed-grants path (read_write user + org), the non-member gate over an
org grant, and author-self-grant pruning.

Part of #1123
Add owner-to-owner skill transfer — the first path that mutates the
otherwise-immutable `createdBy`.

- repository `transferOwnership`: the single explicit `createdBy` write,
  refreshing the cached owner labels and replacing the ACL (dual-writing
  legacy lists), kept separate from `update` so the immutability invariant
  stays obvious.
- service `transferSkillOwnership`: rejects a no-op transfer to the current
  owner (`ownership_conflict`, 409), and recomputes grants so the prior
  owner is appended as a READ grantee (keeps visibility, loses edit/admin)
  while the new owner is dropped from any prior grant (they hold implicit
  ADMIN now).
- route `POST /skills/:id/transfer-ownership`: ADMIN-gated (`canManageSkill`
  — never a read_write grant); validates the target against the injected
  user directory (`invalid_transfer_target`, 400, when unknown); emits the
  new `skill.ownership_transferred` analytics event; resyncs the mirror to
  refresh stale author labels. Immediate/unilateral per the agreed design.
- bootstrap wires `resolveUser` from the user directory repo.

Service tests cover label refresh + prior-owner read grant + new-owner
drop + ownership_conflict; route tests cover the 403/404/409/400 matrix,
the owner happy path, and platform-admin force-transfer.

Part of #1123
Mirror skill transfer onto skillsets. Because the skillset routes
delegate authorization to the service (the route layer has no repo), the
service owns the whole flow — ADMIN gate (`canManageSkill`), no-op
rejection (`ownership_conflict`), AND target resolution against the
injected user directory. Resolving inside the service (rather than the
route) means a non-owner can't enumerate users via the 400-vs-403
difference. The repository gets the same dedicated `transferOwnership`
write (the one explicit `createdBy` mutation), refreshing owner labels +
replacing the ACL with the prior owner kept as a READ grantee.

`resolveUser` is injected into `SkillsetService` via `wireSkillsets`,
backed by the same user-directory repo as the skills path.

Service tests cover owner reassignment + prior-owner read grant, the
non-owner 403, the no-op 409, and the unresolvable-target 400; route
tests cover the permission gate, delegation, and body validation.

Part of #1123
Minor bump for ornn-api + ornn-web (fixed-version pair). The SDKs release
on a separate cadence and are not part of the changeset set.

Part of #1123
Frontend plumbing for the new API surface, no UI yet:

- `SkillPermissionLevel` + `SkillGrant` domain types; `grants?` added to
  `SkillDetail`.
- `permissionsApi`: `SkillPermissionsInput.grants` (legacy `sharedWith*`
  arrays kept optional for back-compat) + a `transferSkillOwnership` POST.
- `useTransferSkillOwnership(skillGuid, idOrName)` mutation following the
  established #750 two-id split + invalidation (detail, lists, my-skills,
  shared-with-me) so the new owner redraws and the owner-only UI drops
  after a transfer.

Part of #1123
Add a "Transfer ownership" action above Delete in the owner-only Danger
Zone. `TransferOwnershipModal` reuses the directory email-typeahead (the
same `searchUsersByEmail` source the PermissionsModal uses, NOT the
admin-only picker) for a single-target select, then requires the owner to
type the skill name before the danger-styled Transfer button enables.

On success the caller is no longer the owner, so the existing list/detail
invalidation in `useTransferSkillOwnership` refetches the detail — the new
owner renders and the owner-only Danger Zone drops away. Modal state lives
in `useSkillDetail` alongside the other modal flags.

Tests cover the two-step gate (target + exact name) and that confirming
fires the mutation with the selected user id.

Part of #1123
#1123)

The PermissionsModal now drives the typed `grants` ACL instead of bare id
lists:

- each granted user (chip) and checked org (row) carries a compact
  read / read-write toggle (`LevelToggle`); new grants default to read.
- form state initializes per-grant levels from `skill.grants` (falling
  back to read-level from the legacy lists), the resolve-labels effect
  preserves the level, and Save builds + sends typed `grants` with
  level-aware change detection (a level flip alone now counts as a change).
- unresolved (ghost) user grants hide the toggle — you can only revoke them.
- `SkillVisibilityCard` gains a "N can edit" line counting read-write grants.

A focused test covers init-from-grants, the read-write→read flip, and that
Save sends the canonical typed-grants payload. Full web suite stays green.

Part of #1123
Expose the new permission surface in @chronoai/ornn-sdk:

- `SkillPermissionLevel` + `SkillGrant` types; `grants` on SkillDetail /
  SkillsetDetail; a shared `PermissionsInput` (carries `grants`, legacy
  `sharedWith*` still accepted) re-exported as the skill/skillset aliases.
- `setSkillPermissions(id, …)` — the SDK had no skill permissions setter
  before (only skillsets); this fills the gap.
- `transferSkillOwnership(id, newOwnerUserId)` and
  `transferSkillsetOwnership(id, newOwnerUserId)`.
- `setSkillsetPermissions` now accepts `grants`.

Transport plumbing (envelope/error/zip helpers) extracted into a new
`http.ts` so `client.ts` stays under the 500-line cap after the additions.
8 new tests (transfer success + 403, grants setters + 400 validation);
38 pass; tsc + lint clean.

Part of #1123
Mirror the TypeScript SDK additions in the Python client:

- `SkillPermissionLevel` literal + `SkillGrant` dataclass (with
  from_dict/to_json); optional `grants` parsed onto SkillDetail /
  SkillsetDetail (defaults to [] on pre-#1123 responses).
- `set_skill_permissions(...)` — NEW (no skill permissions setter
  existed before); `set_skillset_permissions(...)` gains a `grants` kwarg;
  a shared `_permissions_payload` builds both bodies and omits `grants`
  from the wire when not supplied (so the server's legacy fallback holds).
- `transfer_skill_ownership(id, new_owner_user_id)` and
  `transfer_skillset_ownership(id, new_owner_user_id)`.

45 pytest pass (py3.9 + py3.12); ruff + strict mypy clean.

Part of #1123
- CONVENTIONS §5.2: reconcile the documented↔code drift — replace the
  fictional `ornn:skill:read/write/admin` catalog with the real route
  scopes (`ornn:skill:{create,read,update,delete}`, `ornn:skill:build`,
  `ornn:playground:use`, `ornn:admin:skill`), and frame them as route
  scopes distinct from object tiers. New §5.4 documents the
  READ / READ_WRITE / ADMIN object-permission matrix, the typed `grants`
  ACL shape, and both transfer-ownership endpoints (with the
  invalid_transfer_target / ownership_conflict behaviors; rides on
  ornn:skill:update, no new scope). §2.6 + §12 updated.
- ERRORS: add `invalid_transfer_target` (400), `ownership_conflict` (409),
  `invalid_permission_level` (400) with client-action guidance + migration
  map rows.
- ARCHITECTURE: add the `skill.ownership_transferred` analytics event and
  the `readWriteGrants` count on `skill.permissions_changed`.

Part of #1123
…1123)

The KB digest is built from README/CONVENTIONS/ARCHITECTURE etc.; the §5.2
scope reconciliation + §5.4 permission-tier additions changed the
CONVENTIONS source, so the committed digest went stale and the
assistant-kb-freshness CI gate failed. Regenerated via
`bun run build:assistant-kb`.

Part of #1123
@chronoai-shining chronoai-shining merged commit 2e558d8 into develop Jun 15, 2026
17 checks passed
@chronoai-shining chronoai-shining deleted the feature/1123-skill-permission-levels branch June 15, 2026 18:30
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.

[Feature] Skill permission levels (read / read-write) + ownership transfer

1 participant