Skip to content

feat(organisations): project the organisation as an object so a $ref can reach it - #3363

Open
rubenvdlinde wants to merge 1 commit into
developmentfrom
feat/organisations-are-objects
Open

feat(organisations): project the organisation as an object so a $ref can reach it#3363
rubenvdlinde wants to merge 1 commit into
developmentfrom
feat/organisations-are-objects

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Why

Several apps grew their own organization schema, and measuring one showed why.

publication.organization and catalog.organization are declared as {"type": "string", "format": "uuid", "$ref": "organization"}. A $ref resolves against a schema, and OpenRegister's Organisation is an entity with no object projection — so there was nothing for that reference to point at, and each app declared its own copy instead.

A schema slug is global per organisation (SchemaMapper::find() matches LOWER(slug) across every app and returns the first row it reaches), so those copies collide. organization is claimed by both opencatalogi and stackiq today.

Adding the identity columns to Organisation made reuse possible at the entity level, and openregister:organisations:adopt (#3340) moves the rows. Neither gives a leaf schema something to reference. This is the missing piece.

Not a new mechanism

OpenRegister already projects nc-user and nc-group exactly this way — and nc-group is even mapped to schema:Organization. OrganisationObjectSourceProvider follows GroupObjectSourceProvider line for line: the same read-only contract, the same acting-user scoping, the same "absent and denied are indistinguishable" rule.

The schema is nc--prefixed for the reason the semantic map already gives for its app-gated rows: it must not collide with the leaf-app organization schemas it exists to replace, which have to keep working until each app has migrated off them.

Three deliberate limits

  • Read-only. The authoritative record is the Organisation row. A write path here would be a second way to mutate a tenant, reachable through the object API and bypassing OrganisationService's lifecycle.
  • The identity facet only. Quota, users, groups and authorization are tenancy administration. This schema exists so another record can reference an organisation, not so anyone can configure one through the object API.
  • Scoped, and not an enumeration oracle. An organisation is the tenant boundary. Admin sees all; anyone else sees only theirs; absent and denied both return null; anonymous sees nothing.

A merged-away organisation is not listed — it owns nothing, and offering it invites a reference to an unusable target. find() still resolves through a merge, so a reference stored before one keeps working.

A defect the tests caught and reading did not

project() was first written with method_exists($organisation, 'getName') before calling the getter. Organisation's accessors are magic (Entity::__call), so method_exists is false for every one of them — the projection would have shipped carrying nothing but an id. A schema that resolves, returns objects, and is empty.

Verification

Live, on a throwaway instance:

Check Result
GET /api/objects/{directory}/{nc-organisation} as admin returns the organisation with its identity facet
the same call unauthenticated total: 0 — not an error, not a row
find by uuid returns the organisation
seeding on a clean row creates the schema with its properties and links it to the directory register

10 unit tests pin the projected set, the omissions (quota/users/groups/authorization), the merge exclusion, search, and the anonymous case. A script asserts the provider's projected list and the seeder's declared properties agree, rather than eyeballing it — a property declared and not projected reads as permanently empty, and one projected and not declared is discarded by the store without a word.

Full-tree phpstan clean. phpcs 0 errors, phpmd clean. tests/Unit/Service/ObjectSource: 115 tests green.

One thing worth knowing

ensureSchema() reuses an existing schema and never updates it, by design. A property added to SCHEMA_PROPERTIES therefore does not reach an instance that already seeded that schema — verified by deleting the row and re-running the repair step. Any future property change to a virtual schema needs its own migration. Recorded in the change's tasks.

What this unblocks

opencatalogi can now repoint publication.organization / catalog.organization at nc-organisation and retire its own copy. Stackiq can follow, once the nine properties Organisation has no column for get a home.

🤖 Generated with Claude Code

…can reach it

Several apps grew their own `organization` SCHEMA, and measuring one
showed why. `publication.organization` and `catalog.organization` are
declared as `{"format": "uuid", "$ref": "organization"}`. A $ref resolves
against a SCHEMA, and OpenRegister's Organisation is an ENTITY with no
object projection, so there was nothing for that reference to point at
and each app declared its own copy. A schema slug is global per
organisation, so those copies collide: `organization` is claimed by both
opencatalogi and stackiq today.

Adding the identity columns made reuse possible at the entity level and
`organisations:adopt` moves the rows. Neither gives a leaf schema
something to reference. This does.

Not a new mechanism: OpenRegister already projects nc-user and nc-group
exactly this way, and nc-group is even mapped to schema:Organization.
The provider follows GroupObjectSourceProvider line for line, including
the rule that absent and denied are indistinguishable. The schema is
nc-prefixed for the reason the semantic map already gives for the
app-gated rows: it must not collide with the leaf `organization` schemas
it exists to replace, which keep working until each app migrates off.

Three deliberate limits. It is READ-ONLY, because the authoritative
record is the Organisation row and a write path here would be a second
way to mutate a tenant that bypasses the organisation lifecycle. It
carries the IDENTITY FACET ONLY, because quota, users, groups and
authorization are tenancy administration and this schema exists so a
record can reference an organisation rather than configure one. And it is
SCOPED: an admin sees every organisation, anyone else only the ones they
belong to, anonymous callers nothing, so the projection cannot be used to
discover which tenants an instance has.

A merged-away organisation is not listed, because it owns nothing and
offering it invites a reference to an unusable target. find() still
resolves THROUGH a merge, so a reference stored before one keeps working.

One defect the tests caught and reading did not: project() first used
method_exists($organisation, 'getName') before calling the getter.
Organisation's accessors are magic, so method_exists is false for every
one of them and the projection would have shipped carrying nothing but an
id — a schema that resolves, returns objects, and is empty.

Verified live: the object API returns the organisation with its identity
facet, the same call unauthenticated returns total 0 rather than an
error, find by uuid works, and seeding on a clean row creates the schema
WITH its properties and links it to the directory register.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant