feat(identity): a person, their identities, and the organizations that are theirs - #133
Open
AndrejK666 wants to merge 1 commit into
Open
feat(identity): a person, their identities, and the organizations that are theirs#133AndrejK666 wants to merge 1 commit into
AndrejK666 wants to merge 1 commit into
Conversation
…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>
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: Comment |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
Eight pieces, in the order each one unblocked the next:
on_first_loginandself_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.tenant_idattribute is retired. Both gates that read it are gone, and every profile names its administrator instead. ADR-0016's follow-up closes with it.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 alland, 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.