Persistence¶
The provider keeps its model with Doctrine ORM. The classes of the core, of OpenID Connect and of SAML know nothing of Doctrine: the bundle maps them from XML files of its own, declares their types and their migrations to Doctrine itself, and the application writes none of it. The schema is therefore the same whether the bundle is installed in an existing application or deployed in the standalone one.
What the bundle maps¶
| Mapping | Directory of the bundle | Namespace |
|---|---|---|
BeffroiCore |
config/doctrine/core |
Beffroi\Core\Domain |
BeffroiOidc |
config/doctrine/oidc |
Beffroi\Oidc\Domain |
BeffroiSaml |
config/doctrine/saml |
Beffroi\Saml\Domain |
One XML file per class, named after it, as Identity.User.orm.xml. The application maps its own entities
beside them and none of them is ever a user of the provider: a person of the provider is a row of
beffroi_user, whatever the host application calls its own users.
The tables¶
Every table is prefixed beffroi_, and every table of the mapping but beffroi_tenant carries the tenant
it belongs to, by a foreign key on tenant_id.
| Table | What a row is |
|---|---|
beffroi_tenant |
The tenant: its issuer, its slug, its name, its look, the claims its people may change, its acr vocabulary and the floor of its logins |
beffroi_user |
A person: the subject, in the clear and unique in the tenant, the username, the e-mail address, the telephone number, the postal address, the claims of the profile and the source that created them |
beffroi_password_credential |
The password of a person, its hash typed by its algorithm, and when a list of compromised passwords was last asked about it and when it was found there |
beffroi_passkey_credential |
A passkey: the credential record of WebAuthn, its name and its revocation |
beffroi_totp_credential |
An authenticator application: its secret, the last time step accepted and the hashed backup codes |
beffroi_enrolment_ticket |
The ticket that opens one enrolment of a first passkey, by a hash of its value |
beffroi_linked_identity |
Who a person is at an upstream provider, and what that provider said of them |
beffroi_group, beffroi_group_membership |
The groups of the tenant and who belongs to them, each with its source |
beffroi_push_subscription |
A browser of a person the provider can reach by web push |
beffroi_authentication_session |
A session: what it proved, from where it was opened and last seen, when it ends and why it ended |
beffroi_journal |
One act of the provider |
beffroi_claim_change |
What a claim of a person said before they changed it, and what it says now |
beffroi_personal_archive |
The archive a person asked for of everything the provider holds of them |
| Table | What a row is |
|---|---|
beffroi_client |
A client the provider registered itself. A client declared in the configuration has no row |
beffroi_initial_access_token |
An initial access token, by the digest of its value |
beffroi_interaction |
An authorization request parked while the person signs in and consents |
beffroi_pushed_authorization_request |
A pushed request, by the hash of its reference, its parameters as the client wrote them |
beffroi_consent |
What a person agreed to let a client obtain |
beffroi_authorization_code |
A code, by its hash, kept past its use so that a replay is told from an unknown code |
beffroi_device_authorization_request |
A device authorization request, by the hash of the code the device polls with |
beffroi_backchannel_authentication_request |
A backchannel authentication request, by the hash of the identifier the client comes back with |
beffroi_refresh_token, beffroi_refresh_token_family |
A refresh token by its hash, and the lineage every token of a grant names |
beffroi_access_token, beffroi_client_access_token, beffroi_subject_access_token |
An opaque access token by its hash: one somebody signed in for, one a client holds for itself, one whose subject was named by what the client presented |
beffroi_pairwise_subject |
The identifier one sector sees a person under |
beffroi_disclosure |
What a client was given of a person, at one act of the protocol |
| Table | What a row is |
|---|---|
beffroi_saml_service_provider |
The newest metadata document accepted for a service provider |
beffroi_saml_upstream_identity_provider |
The newest metadata document accepted for an upstream identity provider |
beffroi_saml_remembered_message |
An assertion or a logout message already accepted, until the window in which it would be accepted again has closed |
beffroi_saml_session_participant |
A service provider that took part in a session |
beffroi_saml_logout_chain |
A single logout the provider plays through the browser that began it |
| Table | What a row is |
|---|---|
beffroi_data_key |
A data key of a tenant, wrapped by its master key, with the client and the master key that wrapped it. No mapping describes it: it is the table of the data key store, created by a migration of the bundle and declared to the schema the ORM assembles, so that a comparison of the schema does not offer to drop it |
The keys of the tenant are not in the database. Each one is a file of beffroi.keys.storage, its private
half sealed by the key management service.
The migrations¶
The bundle ships its migrations in its own directory, under the namespace Beffroi\Bundle\Migrations, and
declares them to Doctrine Migrations. They are played with those of the application:
They are written for PostgreSQL, and each one stops on another platform with This migration is written
for PostgreSQL.
A migration that seals columns says two things before it runs. It refuses a table that holds rows, naming the table and the count: a sealed column is binary and refuses a null, and nothing puts a value in clear into it, so the rows are sealed by a script of the deployment's own or the table is emptied. And it says to restart what serves requests and what consumes the buses once it has run, because a process that booted before it reads those columns with the type they had.
A value object in one column¶
A value object a column holds whole goes in one column, with a DBAL type of the bundle: the bundle
declares thirty-three of them, beffroi_subject, beffroi_scopes, beffroi_request_context or
beffroi_password_hash among them. What the column holds is written by the object itself, in the package
that owns it, and read back through its constructor, so a row edited by hand into something the object
refuses fails when it is loaded, as a conversion error naming the type, and never further down.
Some of them read the wiring back. A stored password hash names its algorithm, a confirmation of
RFC 8705 or RFC 9449 names its kind, an authorization_details names its type, a PKCE challenge names its
method, and the acr vocabulary and the floor of a tenant name their predicates. Each is read back as the
class the deployment wires under that name, so a row naming something the deployment no longer wires fails
at load rather than being read as something else: a password algorithm leaves the wiring once no hash of it
is left, or every row holding one fails to load.
One embeddable is left in the mapping, the authorization request, embedded by beffroi_interaction and
by nothing else. An embeddable fixes the type of each of its fields once for the whole model, so the same
value could not be sealed in one table and readable in another; this one stays because its first field is a
client, which no mapping carries, so no DBAL type could hand the object back whole.
A client is not a row a table joins. A client declared in the configuration has no row at all, so the
tables keep client_id, and the client is put back on a row once it is loaded, from the sources of clients
the deployment wires. A row whose client is no longer registered, removed from the configuration or deleted,
is treated as absent: a token or a consent of a client that does not exist is not one.
Sealed columns¶
Thirty-three columns hold a personal datum, or the secret of a client, sealed under a data key of their tenant. A data key is minted per tenant and per family of columns, the family being the name of the table without its prefix, so the key that opens one table of a tenant opens no other table and no other tenant.
| Family | The columns it seals |
|---|---|
user |
username, email, phone_number, address, profile, source |
linked_identity |
provider, subject, claims |
passkey_credential |
name |
push_subscription |
endpoint, label |
authentication_session |
created_from, last_seen_from |
interaction |
login_hint, id_token_hint |
pushed_authorization_request |
parameters |
device_authorization_request |
requested_from |
backchannel_authentication_request |
binding_message, requested_from |
client |
metadata, assigned_metadata, secret |
journal |
context |
claim_change |
was, became, context |
disclosure |
scopes, claims, authorization_details |
personal_archive |
content |
saml_service_provider |
document |
saml_upstream_identity_provider |
document |
Three columns hold an envelope of the key management service instead, opened under the master key of the tenant: the secret of an authenticator application, and the identifier and the token of a backchannel request whose client the provider calls back.
A value is sealed under the tenant of the unit of work in progress, and a write that has none is refused rather than sealed under a scope of no tenant. The data keys are what the console lists, moves to another client and erases.
What a sealed column changes about a query¶
- It cannot be searched. Every value is sealed under a random initialisation vector, so two writes of the
same username give two ciphertexts and
WHERE username = ?matches nothing. - What is searched is a blind index, a keyed tag of the value in a column beside it, equal for equal
values. Four columns carry one:
beffroi_user.usernameinusername_index,beffroi_user.emailinemail_index, folded whole, local part and domain, and the pairproviderandsubjectofbeffroi_linked_identityinprovider_indexandsubject_index. The unique constraints are held on those columns, a constraint on a sealed column refusing nothing. The key of the tags is one per tenant, a data key of its own that never rotates. - The tag is written by the bundle, on the flush of the entity manager, in the same statement as the
value. A row written through DBAL, or changed by a bulk
UPDATE, keeps whatever tag it had, and a search then finds the row by what it used to hold.beffroi:user:reindexderives the tags of every person of the tenant again. - The column is unbounded binary. The
lengtha mapping gives a sealed column is the length the model checks, not the width of the column, and an option that describes the stored value no longer holds: no query reads into the JSON ofbeffroi_user.address, which is a ciphertext. - A ciphertext is bound to its tenant, not to its row. Within one tenant and one family, a value copied into another row of the same column opens there.
What goes away, and when¶
The protocol writes rows that stop being worth anything, and it keeps them past that moment on purpose, so that a second presentation of a code or a token is told from a value nobody issued. Timed steps of the schedule then drop them, each statement bounded and the next run taking what it left.
| Step | Every | What it drops |
|---|---|---|
| The rows that are dead | Hour | Interactions, pushed requests, codes, device and backchannel requests, refresh tokens, opaque access tokens, enrolment tickets and sessions that nothing alive needs any more |
| The journal | Day | The acts older than beffroi.journal.retention days, 180 by default, and the disclosures with them |
| The history of a record | Day | The changes of claims older than beffroi.history.retention days, 180 by default |
| The archives | Hour | The archives past their expiry, a week after they were asked for |
| The unused clients | Hour | The clients that registered themselves and went unused longer than client_registration.keep_a_client_unused_for, ninety days by default, or were never used within keep_a_client_never_used_for, seven days by default |
A session is dropped last: the codes, refresh tokens and access tokens of a login name it under a foreign key that cascades, and their own time is past before the session's. The SAML store of messages already accepted and the logout chains drop their closed rows before each insertion.