Skip to content

feat(identity): leaving is yours to do, and your credentials go with you - #129

Open
AndrejK666 wants to merge 3 commits into
constructorfabric:feature/create-your-organizationfrom
AndrejK666:AndrejK666/leaving
Open

feat(identity): leaving is yours to do, and your credentials go with you#129
AndrejK666 wants to merge 3 commits into
constructorfabric:feature/create-your-organizationfrom
AndrejK666:AndrejK666/leaving

Conversation

@AndrejK666

Copy link
Copy Markdown
Contributor

Stacked on #126 (AndrejK666/provisioning-profiles). The diff below is cumulative until that one merges; the commit to read here is the last one.

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::leaving is pure policy — no IO, eight tests — and every route that changes a membership passes through it:

situation answer
an ordinary member leaves allowed
one of two owners leaves allowed
the only owner leaves 400 — make somebody else an owner first, then leave
the only owner is demoted the same refusal, in the same words
the only person leaves 400 — leaving would mean deleting the organization, which is a separate, deliberate act
somebody who is not a member 404

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 personal connection 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_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). 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_membership is 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 through scripts/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_by was 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 LastPerson refusal is currently pointing at.

… 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>
@coderabbitai

coderabbitai Bot commented Sep 11, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: a1a909ef-5f0a-4992-9ede-bb59544375d4

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@AndrejK666

Copy link
Copy Markdown
Contributor Author

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.

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