feat(identity): leaving is yours to do, and your credentials go with you - #129
Conversation
… claim in a token Fourth in ADR-0018's order, and the last place where a right was read off the way somebody signed in. `config/oidc.yaml` maps the Keycloak user attribute `tenant_id` into `subject_tenant_id`, and three comparisons against the platform root decided from it whether a caller administers the installation. That attribute belongs to a Keycloak user — a login — so a person with two of them was an administrator through one and an ordinary member through the other. Administering the platform is now holding a membership of the platform root. One spelling of the rule, published so the identity directory and the identity gear cannot drift apart on it. Both signals are accepted while the migration runs, and that is the whole point of doing it in this order: an installation names its administrators in configuration (`platform_admins`), those memberships are seeded idempotently at every start, ADR-0016's backfill already wrote the rows for identities that carried the attribute, and only when both are true everywhere can the token reading go. Removing it before that is a lockout: a deployment whose administrators were only ever administrators by token would have none, and no way to make one. Seeding is logged rather than fatal. An installation that cannot reach its database has a larger problem than an unseeded administrator, and refusing to boot would hide it. VERIFIED ON A STAND, with nobody's token naming the root: administrator — not root in the token, seeded membership → 200 ordinary person — neither → 403 the seeded row → owner / bootstrap The directory's gate uses the same helper; on that stand it answers 503 because Keycloak admin is unconfigured there, so it is covered by construction rather than exercised. Signed-off-by: Andrej Kuchma <Andrej.Kuchma@constructor.tech>
…split stops being two products
Fifth in ADR-0018's order, and the part that makes §4 real: the domain model is
identical whether Studio runs as a cloud service or inside one company. What
differs is only how a person comes by their first organization, and that is now
two settings rather than a deployment mode with branches behind it.
studio-user.config.on_first_login — join this organization when a person
is first seen, as this role
studio-organizations.config
.self_service — may a person create an organization
Cloud leaves the first unset and the second true; an installation inside one
company does the opposite. Nothing else in the model, and no authorization
path, is aware of which of the two it is running as.
WHY AUTO-JOIN IS NOT ACCESS DERIVED FROM AUTHENTICATION
The membership is written at the one moment a person begins to exist, and it is
a row. It can be revoked — suspending somebody in Studio without removing them
from the corporate directory, which is frequently what a company actually wants
— and `membership.source` records that it came from a first login rather than
from an invitation or an operator. Access that followed the token could do
neither. ADR-0011 §1 stands: authentication still grants nothing by itself; the
deployment's statement does, and the statement is data.
The join happens on provisioning, not on every request. If it ran per request a
revoked membership would come straight back, which would make revocation a lie.
`on_first_login.role` may not be `owner`: a deployment does not hand ownership
to everybody who signs in. A configuration that says so is refused with a log
rather than obeyed.
Both new settings fail soft. A join that cannot be written is logged, because a
person who exists but has not joined is a person the next request can still
join, while failing there would leave them unable to sign in at all.
`GET /studio-organizations/v1/capabilities` reports `self_service`, so the
portal's no-organization screen can offer creation where creation is possible
and say "wait for an invitation" where it is not — rather than offering a
control that answers 403.
VERIFIED ON A STAND, by running one deployment as both:
cloud phase capabilities → self_service: true; an organization is created
on-prem phase capabilities → self_service: false
a brand-new subject's first request → member / first_login
creation → 403 SELF_SERVICE_DISABLED, for the administrator too
membership revoked → no memberships, and /me still answers 200
Signed-off-by: Andrej Kuchma <Andrej.Kuchma@constructor.tech>
Membership could be granted and it could be taken away, but nobody could
end their own. The one route that removed a membership was gated on being
the organization's owner, so the person who wanted to walk out had to ask
the people they were walking out on — and the last owner could remove
themselves, leaving an organization with members and nobody able to
administer it.
Two things change.
Leaving is self-service. `DELETE /studio-user/v1/me/memberships/{org_id}`
asks nobody's permission, because leaving is nobody's permission to give.
An organization always has an owner, and one rule now holds that up
wherever a membership changes — leaving, being removed, being demoted.
`user_profile::leaving` states it with no IO and eight tests: an ordinary
member always may; one of two owners may; the only owner is told to
appoint another first; and the only person in an organization is told
that leaving it would mean deleting it, which is a separate, deliberate
act. Demoting the only owner is refused in the same words as removing
them, so the invariant cannot be enforced on one route and walked around
on another.
What goes with the leaver is their personal connections. A personal
connection holds their own credential, and under ADR-0012 the record is
also their proof of controlling that external account: an organization
they are no longer part of must keep neither. Shared connections belong
to the organization and stay, and so do documents, projects and
authorship — history is not rewritten because somebody left. `created_by`
is resolved to a person before it is compared, so a connection created
under one of their logins goes with them when they leave under another
(ADR-0014).
`remove_membership` is gone rather than left beside the new path: a
second door that skips the gate and leaves the credentials behind is
exactly what this is meant to close.
Verified on an isolated stand (own Postgres, static tokens): the only
person is refused; a member leaves; the only owner is refused until
somebody else is made owner; demoting the only owner is refused; a
non-member gets a 404; and on leaving, the leaver's personal connection
disappears while the shared one and another person's personal one stay —
including a connection created under a login that had since been merged
into the leaver.
Signed-off-by: Andrej Kuchma <Andrej.Kuchma@constructor.tech>
|
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:
Comment |
|
Folded into #132, which carries this branch's commits unchanged along with the four pieces that finish the scope. Closing this one in favour of that is safe — nothing here is lost. |
What was wrong
Membership could be granted and taken away, but nobody could end their own. The only route that removed a membership was gated on being the organization's owner, so a person who wanted to walk out had to ask the people they were walking out on.
And the rule ADR-0011 §4 states — an organization always has an owner — was stated nowhere in code. The last owner could remove themselves and leave an organization with members in it and nobody able to administer them.
What this does
Leaving is self-service.
DELETE /studio-user/v1/me/memberships/{org_id}asks nobody's permission, because leaving is nobody's permission to give.One rule, in one place.
user_profile::leavingis pure policy — no IO, eight tests — and every route that changes a membership passes through it:Demotion is refused in the same words as removal on purpose: otherwise the invariant would hold on one route and be walked around on another. The owner's
DELETE /users/{user_id}/memberships/{org_id}now goes through the same path as leaving — removed by an owner or walking out alone, it is the same departure.The leaver's credentials go with them. A
personalconnection holds that person's own credential, and under ADR-0012 the record is also their proof of controlling that external account — an organization they are no longer part of must keep neither.created_byis resolved to a person before it is compared, so a connection created under one of their logins goes with them when they leave under another (ADR-0014). Shared connections belong to the organization and stay; so do documents, projects and authorship — history is not rewritten because somebody left. The response says how many went:{"connections_removed": 1}.remove_membershipis deleted rather than left beside the new path. A second door that skips the gate and leaves the credentials behind is exactly what this closes.Verification
fmt,clippy --all-targets -D warnings,cargo test(517 pass), and both feature-set checks, all throughscripts/backend-check.sh.Then an isolated stand — its own Postgres, static tokens, no shared stack — where a person created an organization and the six rows of the table above were each exercised over HTTP. For the credentials: a catalogue with three connections (the leaver's personal one, a shared one, and another person's personal one) was seeded on the organization; after leaving, only the leaver's was gone. Repeated with a connection whose
created_bywas a different login that had since been merged into the leaver — it went with them too.Still to come
Deleting an organization, which is what the
LastPersonrefusal is currently pointing at.