Skip to content

Commit b9daeeb

Browse files
Merge pull request #1130 from ChronoAIProject/develop
Release v0.13.0 — skill & skillset permissions
2 parents f945d0e + 82387a6 commit b9daeeb

60 files changed

Lines changed: 4143 additions & 1140 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"ornn-api": minor
3+
"ornn-web": minor
4+
---
5+
6+
Manage Permissions: separate Read and Write access into two tabs.
7+
8+
The skill and skillset permissions editor is now a two-tab editor — a **Read** tab (Public toggle, or a restricted set of orgs/users who can read) and a **Write** tab (orgs/users who can edit). This makes every combination the backend already supports expressible, including **public read + organization/user write**, which the previous single-visibility-ladder couldn't reach. Skillsets also gain the read / read-write level support they previously lacked. No API changes — purely a UI redesign over the existing typed `grants` model.

.changeset/read-write-split.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"ornn-api": minor
3+
"ornn-web": minor
4+
---
5+
6+
Separate read and write access (drop the `read_write` level) and show edit UI to write-grantees.
7+
8+
The combined `read_write` grant level is renamed to `write`: each grant is now `read` or `write` (a `write` grant implies read), with no combined label. Existing `read_write` grants migrate to `write` automatically at boot — non-disruptive and rollback-safe.
9+
10+
This also fixes a bug where a user with **write** access saw no edit controls: the skill detail page (Edit button, inline file editor, Save) and the edit page now reveal content-editing to write-grantees, not just the owner, while admin actions (permissions, transfer, delete, visibility) remain owner/admin-only.

.changeset/release-prep-v0-13-0.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
Release prep: adds the dated release-notes file for v0.13.0. No package bump.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"ornn-api": minor
3+
"ornn-web": minor
4+
---
5+
6+
Skill & skillset permission levels (read / read-write) and ownership transfer.
7+
8+
Skills and skillsets now carry a typed `grants` ACL where each user/org grant has a level: **read** (view / pull / execute) or **read-write** (also update the skill's content & metadata). Read-write grantees cannot manage permissions, transfer ownership, or delete — those admin/danger-zone actions stay with the owner and platform admins only.
9+
10+
Owners (and platform admins) can **transfer a skill or skillset to another Ornn user** from the Danger Zone; the transfer is immediate and the prior owner keeps read access. All existing shares (public, org, and per-user) migrate to read-only with no disruption to current access.

.github/release-notes-20260616.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
## Fixed
2+
3+
- Few technical bugs fixed
4+
5+
## New Feature
6+
7+
- Share skills and skillsets at read or write access level
8+
- Transfer skill or skillset ownership to another Ornn user
9+
- Users with write access can now edit shared skills, not just owners
10+
- TypeScript and Python SDKs gain ownership transfer and permission management
11+
12+
## Changed
13+
14+
- Manage Permissions split into separate Read and Write tabs (public read + org write now possible)
15+
- Technical enhancement

docs/ARCHITECTURE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ so dashboards can disambiguate from frontend events of the same name):
4343
| `api.skill.published` | skill create + version publish | `skillId`, `skillVersion`, `isNewSkill` |
4444
| `user.login` / `user.logout` | session open / close ||
4545
| `skill.created` / `.updated` / `.deleted` / `.version_deleted` | mutation routes | `skillId`, `skillName`, `version`, `adminAction?` |
46-
| `skill.visibility_changed` / `.permissions_changed` | visibility + sharing flips | `skillId`, `isPrivate`, `sharedWithUsers`, `sharedWithOrgs` |
46+
| `skill.visibility_changed` / `.permissions_changed` | visibility + sharing flips | `skillId`, `isPrivate`, `sharedWithUsers`, `sharedWithOrgs`, `writeGrants` (count of `write` grants, #1123) |
47+
| `skill.ownership_transferred` | ownership handed to another user (#1123) | `skillId`, `skillName`, `priorOwnerId`, `newOwnerId` |
4748
| `skill.refresh` / `.source_linked` / `.source_unlinked` | source-pointer ops | `skillId`, `repo`, `ref`, `commit` |
4849
| `skill.nyxid_service_tied` / `.agentseal_rescanned` | tie + admin-rescan | `skillId`, `isSystemSkill`, `score` |
4950
| `settings.exported` / `.imported` | settings IO | `schemaVersion`, `aggregateStatus`, `dryRun`, `sections` |

docs/CONVENTIONS.md

Lines changed: 96 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ GET /v1/skillsets/{idOrName}/versions — list versions (optional auth)
200200
GET /v1/skillsets/{idOrName}/closure — one-call resolve (optional auth)
201201
PUT /v1/skillsets/{id} — publish a new immutable version (ornn:skill:update)
202202
PUT /v1/skillsets/{id}/permissions — visibility / sharing (ornn:skill:update)
203+
POST /v1/skillsets/{id}/transfer-ownership — hand to another user (ornn:skill:update; ADMIN tier — §5.4)
203204
DELETE /v1/skillsets/{id} — delete + cascade versions (ornn:skill:delete)
204205
GET /v1/skillset-search — discovery by kind / tags / scope (optional auth)
205206
```
@@ -340,19 +341,25 @@ Endpoint-specific. Rules:
340341
- `X-NyxID-Identity-Token` and `X-NyxID-*` headers between proxy and `ornn-api` (internal).
341342
- OpenAPI declares one `bearerAuth` scheme; `X-NyxID-*` is not part of the public contract.
342343

343-
### 5.2 Permission strings
344+
### 5.2 NyxID request scopes (route-level)
344345

345-
Format: `ornn:<resource>:<action>`.
346-
347-
Actions: `read`, `write`, `admin`, plus resource-specific high-cost actions when needed.
346+
Format: `ornn:<resource>:<action>`. These are the **request scopes** NyxID
347+
mints onto an access token — `requirePermission(...)` middleware checks them
348+
per route. They gate *who may call an endpoint at all*; they are **distinct
349+
from** the per-object READ / WRITE / ADMIN tier (§5.4), which decides
350+
*what the caller may do to a specific skill/skillset*. A caller needs both:
351+
the route scope to reach the handler, and the object tier to act on the
352+
target.
348353

349354
| Permission | Grants |
350355
|---|---|
351356
| `ornn:skill:read` | Read skills (respects visibility) |
352-
| `ornn:skill:write` | Create, update, delete own skills |
353-
| `ornn:skill:admin` | Manage any skill (override ownership); delete any skill |
354-
| `ornn:skill:generate` | Invoke skill generation endpoints (high LLM cost) |
355-
| `ornn:skill:execute` | Invoke playground chat (runs user code) |
357+
| `ornn:skill:create` | Create skills (upload, pull from GitHub) |
358+
| `ornn:skill:update` | Update / publish / refresh / change permissions / transfer ownership / toggle deprecation / bind NyxID service (+ object ADMIN/WRITE per §5.4) |
359+
| `ornn:skill:delete` | Delete a skill or a single version (+ object ADMIN per §5.4) |
360+
| `ornn:skill:build` | Invoke skill generation endpoints (high LLM cost) |
361+
| `ornn:playground:use` | Invoke playground chat (runs user code) |
362+
| `ornn:admin:skill` | Platform-admin bypass — manage any skill/skillset (override ownership), plus all `/admin/*`, force-audit, and platform settings |
356363
| `ornn:category:read` | List categories |
357364
| `ornn:category:admin` | Manage categories |
358365
| `ornn:tag:read` | List tags |
@@ -361,14 +368,91 @@ Actions: `read`, `write`, `admin`, plus resource-specific high-cost actions when
361368
| `ornn:activity:read` | Platform activity log read access |
362369
| `ornn:stats:read` | Platform-wide dashboard aggregates |
363370

364-
NyxID composes a **"Platform Admin"** role that grants all `*:admin` + `*:read` permissions above; current platform admins inherit this role with zero UX change. Sub-admin roles (content moderator, tag curator, support) can be composed from subsets when needed.
371+
Skillset endpoints **reuse** the `ornn:skill:{create,read,update,delete}`
372+
scopes verbatim (§2.6) — there is no `ornn:skillset:*` scope split in v1.
373+
374+
NyxID composes a **"Platform Admin"** role around `ornn:admin:skill` (plus the
375+
`*:admin` + `*:read` scopes above); a token carrying `ornn:admin:skill`
376+
bypasses object ownership on every skill/skillset operation. Current platform
377+
admins inherit this role with zero UX change. Sub-admin roles (content
378+
moderator, tag curator, support) can be composed from subsets when needed.
365379

366-
Adding a new permission requires convention-doc update. NyxID role → permission mapping is owned by NyxID config; this doc is the permission catalog.
380+
Adding a new permission requires convention-doc update. NyxID role →
381+
permission mapping is owned by NyxID config; this doc is the permission
382+
catalog.
367383

368384
### 5.3 Scope declaration
369385

370386
Every route in OpenAPI tagged with its required scopes. Public endpoints explicitly declare `security: []`.
371387

388+
### 5.4 Object-level permission tiers (#1123)
389+
390+
Independent of the request scopes above, every **skill AND skillset** carries
391+
a three-tier object-permission model. All three gates derive from one source
392+
of truth — `ornn-api/src/domains/skills/crud/authorize.ts` (`canReadSkill` /
393+
`canWriteSkill` / `canManageSkill`) — and skillsets reuse the same gates. A
394+
request scope decides whether the caller may *call* the endpoint; the object
395+
tier decides whether they may act on *this specific* skill/skillset.
396+
397+
| Tier | Gate | Who qualifies | What it grants |
398+
|---|---|---|---|
399+
| **READ** | `canReadSkill` | Public skill → anyone. Private → author, platform admin, or any grantee (`read` **or** `write`), directly or via a granted org. | View / pull / execute / list versions. |
400+
| **WRITE** | `canWriteSkill` | Author **OR** platform admin **OR** a `write` grantee (direct or via a granted org). | READ, plus update the skill's **content + metadata only** (publish a new version). |
401+
| **ADMIN** | `canManageSkill` | Author **OR** platform admin **only**. | Change permissions, transfer ownership, delete skill/version, toggle deprecation, manage dist-tags, bind a NyxID service. |
402+
403+
A `write` grantee is **never** an admin — the danger-zone operations stay
404+
with the author (`createdBy`) and platform admins (`ornn:admin:skill`). Org
405+
grants resolve uniformly: every admin/member of a granted org inherits the
406+
grant's level. The org-membership gates fail soft on an unresolved NyxID
407+
lookup (deny) — they never grant on a "couldn't ask" result.
408+
409+
#### Typed grant ACL (`grants`)
410+
411+
The canonical access-control list is a typed `grants` array, exposed on
412+
skill/skillset detail responses and accepted by the permissions endpoints
413+
(`PUT /v1/skills/:id/permissions`, `PUT /v1/skillsets/:id/permissions`):
414+
415+
```json
416+
{
417+
"grants": [
418+
{ "type": "user", "id": "<nyxid-person-user-id>", "level": "read" },
419+
{ "type": "org", "id": "<nyxid-org-user-id>", "level": "write" }
420+
]
421+
}
422+
```
423+
424+
- `type``"user"` (a NyxID person user_id) or `"org"` (a NyxID org user_id).
425+
- `id` — the principal's NyxID id (1..128 chars).
426+
- `level``"read"` or `"write"` (a `write` grant implies read). An invalid
427+
value is rejected with `invalid_permission_level` (a `validation_error`
428+
subcode — see `docs/ERRORS.md`).
429+
430+
The author (`createdBy`) is never represented in `grants` — they hold implicit
431+
ADMIN. The legacy read-only `sharedWithUsers` / `sharedWithOrgs` arrays are
432+
still **accepted** on the permissions endpoints and still **returned** for
433+
back-compat; any principal supplied through them is treated as a `read`-level
434+
grant. A skill predating typed grants authorizes exactly as before.
435+
436+
#### Ownership transfer
437+
438+
```
439+
POST /v1/skills/:id/transfer-ownership { "newOwnerUserId": "<id>" }
440+
POST /v1/skillsets/:id/transfer-ownership { "newOwnerUserId": "<id>" }
441+
```
442+
443+
- **Auth:** ADMIN tier (`canManageSkill`) — author or platform admin only. A
444+
`write` grantee can never transfer. Rides on the existing
445+
`ornn:skill:update` request scope; **no new scope** was added.
446+
- **Behavior:** immediate, synchronous transfer. The target becomes the new
447+
owner (`createdBy`); the prior owner is kept as a **READ** grantee (retains
448+
visibility, loses edit/admin rights).
449+
- **Target validation:** `newOwnerUserId` must resolve to a known Ornn user —
450+
someone who has signed in to Ornn at least once. An unresolvable target is
451+
rejected with `invalid_transfer_target` (400).
452+
- **No-op guard:** transferring to the current owner returns
453+
`ownership_conflict` (409).
454+
- Returns the updated resource (`{ data: { skill | skillset }, error: null }`).
455+
372456
---
373457

374458
## 6. SSE streaming
@@ -543,7 +627,8 @@ Per-test teardown is the test's responsibility; shared fixtures live in `tests/f
543627
- [ ] Error response uses `application/problem+json` with a code from the catalog
544628
- [ ] `X-Request-ID` on every response; `requestId` in every error body
545629
- [ ] Query params camelCase; arrays as repeated keys; `q` for search
546-
- [ ] Required permissions from the catalog declared in OpenAPI `security`
630+
- [ ] Required request scopes from the catalog declared in OpenAPI `security` (§5.2)
631+
- [ ] Object-level authz, where the target is an owned resource, gates on the READ / WRITE / ADMIN tier (§5.4) — distinct from the route scope
547632
- [ ] Content negotiation for multi-representation resources
548633
- [ ] SSE events named `<resource>_<event>` snake_case
549634
- [ ] Deprecation uses RFC 8594 headers

docs/ERRORS.md

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ The pre-#585 `SCREAMING_SNAKE_CASE` shape (`SKILL_NOT_FOUND`, `INVALID_BODY`,
3636
## validation_error
3737

3838
**HTTP:** `400 Bad Request`
39-
**Common subcodes (lowercase post-#585):** `invalid_body`, `invalid_query`, `invalid_params`, `invalid_*` (per-field), `empty_body`, `missing_*`, `frontmatter_validation_failed`, `invalid_permissions`, `invalid_zip`, …
39+
**Common subcodes (lowercase post-#585):** `invalid_body`, `invalid_query`, `invalid_params`, `invalid_*` (per-field), `empty_body`, `missing_*`, `frontmatter_validation_failed`, `invalid_permissions`, `invalid_permission_level`, `invalid_transfer_target`, `invalid_zip`, …
4040

4141
Request body, query string, or path parameter failed validation. Per-field details are in `errors[]`.
4242

@@ -65,6 +65,18 @@ The uploaded payload is not a parseable ZIP — a malformed or unreadable archiv
6565

6666
**Client action:** re-create the ZIP and re-upload; do not retry the same bytes.
6767

68+
### invalid_permission_level
69+
70+
A typed `grants` entry on a permissions request (#1123) carried a `level` outside the allowed set. The only accepted values are `read` and `write` (see [`docs/CONVENTIONS.md`](CONVENTIONS.md) §5.4). Surfaced from `PUT /api/v1/skills/{id}/permissions` and `PUT /api/v1/skillsets/{id}/permissions`.
71+
72+
**Client action:** set every grant's `level` to `read` or `write` and retry.
73+
74+
### invalid_transfer_target
75+
76+
The `newOwnerUserId` supplied to `POST /api/v1/skills/{id}/transfer-ownership` or `POST /api/v1/skillsets/{id}/transfer-ownership` (#1123) does not resolve to a known Ornn user — the target has never signed in to Ornn, so the directory cannot resolve them. The transfer is rejected before any mutation.
77+
78+
**Client action:** confirm the target user has signed in to Ornn at least once, then retry with their resolved user id. Do not retry the same id without that change.
79+
6880
---
6981

7082
## authentication_required
@@ -109,7 +121,7 @@ A skill in a dependency closure (#968) could not be resolved — either the refe
109121
## resource_conflict
110122

111123
**HTTP:** `409 Conflict`
112-
**Common subcodes (lowercase post-#585):** `skill_name_exists`, `skillset_name_exists`, `skillset_version_exists`, `dependency_cycle`, `dependency_conflict`, `reconcile_already_running`, `redemption_code_expired`, `redemption_code_already_redeemed`, `redemption_code_already_invalidated`, `old_repo_not_confirmed`, …
124+
**Common subcodes (lowercase post-#585):** `skill_name_exists`, `skillset_name_exists`, `skillset_version_exists`, `dependency_cycle`, `dependency_conflict`, `ownership_conflict`, `reconcile_already_running`, `redemption_code_expired`, `redemption_code_already_redeemed`, `redemption_code_already_invalidated`, `old_repo_not_confirmed`, …
113125

114126
The request collides with current state — a duplicate skill name on create, a concurrent modification, a job that's already running, etc.
115127

@@ -127,6 +139,12 @@ Two different versions of the **same** skill appear in one dependency closure (#
127139

128140
**Client action:** align the conflicting pins so every path resolves the skill to the same `<major.minor>` version, then retry.
129141

142+
### ownership_conflict
143+
144+
A `POST /api/v1/skills/{id}/transfer-ownership` or `POST /api/v1/skillsets/{id}/transfer-ownership` (#1123) named the **current** owner as `newOwnerUserId` — a no-op transfer. The target already owns the resource, so the request is rejected rather than silently succeeding.
145+
146+
**Client action:** if a transfer is actually intended, supply a different `newOwnerUserId`. If the resource is already owned by the intended user, no action is needed.
147+
130148
---
131149

132150
## payload_too_large
@@ -241,6 +259,9 @@ Clients pinned to the old `SCREAMING_SNAKE_CASE` codes need to switch to the low
241259
| _(new in #968)_ | 404 | `skill_dependency_not_found` | `resource_not_found` |
242260
| _(new in #968)_ | 409 | `dependency_cycle` | `resource_conflict` |
243261
| _(new in #968)_ | 409 | `dependency_conflict` | `resource_conflict` |
262+
| _(new in #1123)_ | 400 | `invalid_permission_level` | `validation_error` |
263+
| _(new in #1123)_ | 400 | `invalid_transfer_target` | `validation_error` |
264+
| _(new in #1123)_ | 409 | `ownership_conflict` | `resource_conflict` |
244265
| `UPSTREAM_DOWN` | 502 | `upstream_down` | `upstream_unavailable` |
245266

246267
Format rule for future codes: lowercase ASCII, words joined by `_`, no leading/trailing `_`. Pick from the parent §1.4 vocabulary when generic; add a specific subcode only when the caller needs to branch on it.

ornn-api/src/bootstrap.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ import { wireAdmin } from "./domains/admin/bootstrap";
146146
// per-provider arrays). One-time, idempotent, runs before any
147147
// LlmProvidersService consumer reads from disk.
148148
import { migrateModelCatalogIntoProviders } from "./domains/settings/llmProviders/migration";
149+
import { backfillTypedGrants, renameReadWriteGrantsToWrite } from "./domains/skills/crud/grants.migration";
149150
import { createLlmPickerRoutes } from "./domains/settings/llmProviders/routes";
150151

151152
// OpenAPI spec
@@ -674,6 +675,33 @@ export async function bootstrap(
674675
),
675676
);
676677

678+
// ---- Typed-grants backfill (#1123) ----
679+
// Fold the legacy read-only `sharedWithUsers` / `sharedWithOrgs` lists into
680+
// the typed `grants` array (every legacy grant → `read` level). One-time,
681+
// idempotent, non-disruptive (legacy lists preserved, nobody escalated to
682+
// write). Runs before any skill/skillset read so the authz gates + scope
683+
// filters can rely on `grants`. Failure is non-fatal: the read-time
684+
// fallback in `effectiveGrants` keeps un-migrated docs authorizing
685+
// correctly off the legacy lists.
686+
await backfillTypedGrants(db).catch((err) =>
687+
logger.error(
688+
{ err: err instanceof Error ? err.message : String(err) },
689+
"typed-grants backfill failed — gates fall back to legacy read lists via effectiveGrants, no data loss",
690+
),
691+
);
692+
693+
// ---- read_write → write grant-level rename (#1127) ----
694+
// The combined `read_write` level was renamed to `write`. Rewrite any
695+
// existing grant carrying the legacy value. Idempotent + non-disruptive
696+
// (write confers what read_write did); `coerceStoredGrants` covers any doc
697+
// not yet rewritten, so failure is non-fatal.
698+
await renameReadWriteGrantsToWrite(db).catch((err) =>
699+
logger.error(
700+
{ err: err instanceof Error ? err.message : String(err) },
701+
"read_write→write rename failed — coerceStoredGrants maps legacy values at read time, no data loss",
702+
),
703+
);
704+
677705
// The picker route — `GET /me/models?surface=...` — reads from the
678706
// per-provider arrays via `LlmProvidersService` (already constructed
679707
// upstream as part of `domains/settings/...`). The section-default
@@ -751,6 +779,10 @@ export async function bootstrap(
751779
// wrapper here.
752780
extraNyxidServicesResolver: () => resolveExtraNyxidServiceNames(),
753781
mirrorService,
782+
// #1123 — transfer-ownership target validation + owner-label refresh,
783+
// backed by the lazily-populated user directory.
784+
resolveUser: async (userId) =>
785+
(await userDirectoryRepo.findByUserIds([userId]))[0] ?? null,
754786
});
755787

756788
// ---- Domain: Skill Search ----
@@ -770,7 +802,13 @@ export async function bootstrap(
770802
// A skillset is a curated, versioned meta-package over N member skills.
771803
// The service injects `skillService` so member resolution + the #968
772804
// closure walk stay single-sourced.
773-
const skillsets = wireSkillsets({ db, skillService });
805+
const skillsets = wireSkillsets({
806+
db,
807+
skillService,
808+
// #1123 — transfer-ownership target validation, shared resolver.
809+
resolveUser: async (userId) =>
810+
(await userDirectoryRepo.findByUserIds([userId]))[0] ?? null,
811+
});
774812
await skillsets.ensureIndexes();
775813

776814
// ---- Domain: Skill Generation ----

0 commit comments

Comments
 (0)