Skip to content

feat(identity): a person, their identities, and the organizations that are theirs - #133

Open
AndrejK666 wants to merge 1 commit into
mainfrom
feature/create-your-organization
Open

feat(identity): a person, their identities, and the organizations that are theirs#133
AndrejK666 wants to merge 1 commit into
mainfrom
feature/create-your-organization

Conversation

@AndrejK666

Copy link
Copy Markdown
Contributor

The integration branch for the person-and-identity work, for main. Merge #132 into this branch first — that one carries the rest of the scope, and this PR is what lands the whole of it.

What this brings to main

A person signs in through any number of identities. The person is what everything is keyed on, and what they may do comes from membership — not from the tenant in their token, and not from which sign-in method they used.

identity ──proves──▶ person ──membership──▶ organization
   (login)            (settings,            (role + status,
                       aliases)              the only authority)

Eight pieces, in the order each one unblocked the next:

  1. The tenant clamp comes from membership, not from the token. The PDP used to scope every request to the subtree of the login's home tenant, so a person who created an organization could not then read it. Nothing self-service worked until this moved.
  2. Creating an organization — one idempotent operation writing the tenant, the owner membership and the owner grant together, so there is no half-created organization nobody owns.
  3. Invitations — an owner invites an address, and only somebody who has proven that address can accept. The token is never stored, only its digest.
  4. A platform administrator is a membership of the platform root, seeded from configuration, not a tenant claim in a token.
  5. Two provisioning settingson_first_login and self_service. These are the whole difference between the cloud product and an installation inside one company: the domain model does not change, only how somebody comes by their first organization.
  6. Leaving, and deleting. Leaving is nobody's permission to give, and the leaver's personal connections go with them — an organization they left must not keep a working credential of theirs. One rule holds the invariant that an organization always has an owner who can act, and every way a membership can change passes through it. Deleting an organization undoes creating one in reverse.
  7. The tenant_id attribute is retired. Both gates that read it are gone, and every profile names its administrator instead. ADR-0016's follow-up closes with it.
  8. membership.status. A suspended membership grants nothing while it stands and still records where somebody belongs. Suspending the only owner is refused in the same words as removing them.

The portal side lands with it: the no-organization screen offers creating an organization and accepting an invitation, and asks the installation which of the two it allows before offering either.

Decisions

docs/adr/0018-an-identity-proves-it-is-you-and-decides-nothing-else.md, which supersedes ADR-0011 §3 and §4, plus the four smaller ADRs this work produced (0014–0017). ADR-0018's follow-up list now says where each one got to, including the one that is deliberately still open.

What is knowingly not here

Enforcement (ADR-0011 §7). Everything above is enforced by the gears: an organization write needs an owner or a platform administrator, membership decides the clamp, a suspended membership reaches nothing. What is missing is the policy half — the Studio PDP maps no resource type to a privilege, so its grant evaluation is unreachable and every request is answered by the tenant clamp. Turning that on means naming privileges, roles and grants, and getting any of it wrong denies requests that work today. It needs its own ADR.

The clamp still admits the platform root from a token. It grants nothing administrative any more, but somebody whose identity provider places them in the root can still see the tenant tree. Closing it means telling a person from a service account apart, which is a question about the platform's subject model rather than Studio's.

Verification

Every piece went through scripts/backend-check.sh all and, separately, an isolated docker stand with its own Postgres — three defects that reading could not have found came out of those stands: the clamp blocker, a PDP cache that went stale between writing a membership and reading it, and a forgeable profile email in the invitation path. The per-piece PRs carry the transcripts.

…accept (#123)

* feat(organizations): a person creates an organization and owns it

The first of ADR-0018's follow-ups. Organizations were created by the browser
calling account-management's createTenant directly, which cannot do this job:
an organization needs a tenant *and* an owner, and a client that writes only
the first produces one nobody owns and nobody sees.

So one server-side operation writes all three things an owner is made of — the
tenant, the membership that is the authority for organization access
(ADR-0011 §2), and the owner grant the Studio PDP evaluates. There is no
transaction across Postgres and account-management, so it is ordered and
resumable instead: membership before grant, because an organization its creator
can see but not administer is a better failure than one they cannot see at all,
and a failure names the organization it created so the same call finishes it.
Each write is idempotent, so resuming is safe however far the first attempt got.

`membership.source` gains its third value, `creation`, beside `assignment` and
`manual` — ADR-0018 §2 wants an owner's member list to show how each person
got in, and creating the place is not the same as being put in it.

The access-config document — the thing the PDP reads — had three private copies
of its shape in this assembly: the directory that writes the owner grant, the
identity gear that asks who owns an organization, and the PDP that evaluates it.
Writing a fourth for this gear would have been three chances of drift becoming
four, so the shape, the read and the write are now one module. The PDP keeps its
own deserialization: it also carries roles and privilege expansion and sits on
the authorization path, where a refactor is not free.

VERIFIED ON A STAND, AND IT FOUND THE BLOCKER
A caller whose token names the platform root creates an organization end to end:
tenant, membership (owner/creation), grant; repeating with organization_id
returns the same organization and leaves the membership count at one.

An ordinary person cannot. Same code, same request, and the grant write fails
with "tenant not found", because the PDP clamps every request to the subtree of
`subject_tenant_id` — the home tenant of the *login* — and an organization just
created under the platform root is outside it. The tenant is created and the
membership is recorded; the creator then cannot administer what they own.

That is the same root cause the whole ADR-0018 line is about, one level deeper
than expected: not only administrative rights but the tenant clamp itself is
derived from the token rather than from membership. Self-service creation
therefore cannot be released to ordinary people until the clamp comes from a
person's memberships, which reorders ADR-0018's follow-ups — the clamp has to
move first. The operation here is correct and complete; what it needs is a
policy that can see what it wrote.

Signed-off-by: Andrej Kuchma <Andrej.Kuchma@constructor.tech>

* feat(authz): the tenant clamp is what a person may reach, not what their token says

Building the create-organization operation turned up the deeper half of
ADR-0018: the PDP clamps every request to the subtree of `subject_tenant_id`,
the home tenant of the *login*. So a person who created an organization could
not then read or administer it — the tenant and the membership were written and
the owner grant failed with "tenant not found", because the thing they had just
created sat outside their clamp.

The clamp is now the union of that tenant and the organizations the person is a
member of. Constraints in a response are OR-ed and predicates inside one are
AND-ed, so "any of these tenants" needs no new platform concept: the flat arm
takes a list, and `InTenantSubtree` carries a single root, so the subtree arms
are one per tenant.

The token's tenant stays in the union on purpose. This change can then only
widen, so nothing that works today stops working — including service accounts,
which have a tenant and no memberships. Removing that arm is a separate step,
after the things still reading it are gone.

The PDP is asked on every request, so the organization list is cached. The first
version of that cache was wrong, and the stand found it: creating an
organization writes the membership and then the owner grant, and the grant write
was authorized against a clamp that had already cached "this person belongs to
nothing" — so the creator could not finish creating their own organization until
the entry expired. Memberships now carry a generation that every write moves,
and a cached answer is good only while that generation holds. The age limit
stays as a backstop.

A failed membership read is not a denial: the caller keeps the reach they had
before memberships were consulted. Failing closed there would make a database
hiccup indistinguishable from a revoked membership.

VERIFIED ON A STAND
An ordinary person — token tenant far from the platform root — creates an
organization on the first attempt, reads it back, and holds two of them. Another
person's organization answers 404 to them while their own answers 200, so the
clamp widened to exactly their memberships and no further.

Signed-off-by: Andrej Kuchma <Andrej.Kuchma@constructor.tech>

* feat(invitations): an owner invites, and only the invited person can accept

Third in ADR-0018's order, and the thing that made the no-membership screen
honest: it told people to ask an administrator, and there was no mechanism
behind that sentence.

An invitation is a bearer secret that becomes a membership, and everything here
follows from that. The token is 244 bits, returned once, and stored only as a
SHA-256 digest — a read of the table yields nothing that works. It expires in
fourteen days. It is single-use, and single use is decided by the database
(`UPDATE … WHERE accepted_at IS NULL`) rather than by a check followed by a
write, so two acceptances racing produce one member and one refusal.

WHAT AN ACCEPTANCE IS MATCHED AGAINST
Not the profile e-mail. That field is self-service (`POST /me`), so matching on
it would let anybody take any invitation by typing the address it was sent to —
an invitation system built on it is worse than none. ADR-0011 §6 says the match
must use a verified address, and we had no source for one: the directory read
Keycloak's `email` and not its `emailVerified`.

So the directory now reads both and publishes `verified_email`, and the trait
it lives on becomes `IdpDirectoryReader` — what the IdP knows about one of its
subjects — rather than being named for federation alone. With no directory
configured, acceptance refuses rather than falling back.

The match runs against every address the person has verified across all their
sign-in methods, not only the one they are holding now. One human, several
logins: an invitation sent to the address on one of them is theirs whichever
way they came in today.

An invitation cannot carry `owner`. Ownership comes from creating an
organization or from an owner handing over deliberately (ADR-0018 §6); a link
somebody forwarded is neither.

Refusals do not distinguish "no such token" from "not yours", because the pair
would be an oracle for guessing tokens. State is decided before identity, so a
caller learns "used" or "expired" about an invitation they hold and nothing at
all about one they do not.

`membership.source` gains its fourth and last value, `invitation`.

VERIFIED ON A STAND, with a real Keycloak and two realm users — one whose
address is verified, one whose is not:

  the impostor holds the token → refused, no verified address
  the invitee sees it waiting  → matched on her verified address
  she accepts                  → membership, source: invitation
  the same token again         → already used
  an invented token            → does not exist
  owner as the invited role    → refused
  the organization's list      → no tokens in it
  revoke, then revoke again    → 204, then 404

Signed-off-by: Andrej Kuchma <Andrej.Kuchma@constructor.tech>

---------

Signed-off-by: Andrej Kuchma <Andrej.Kuchma@constructor.tech>
@coderabbitai

coderabbitai Bot commented Sep 11, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 27ac8130-931d-4c13-89fc-d4cf7f09ffa4


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

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