Approval gate
An identity provider will happily vouch for anyone who holds an account with it. That is its job, and it is not the same question as whether this application should admit them. The approval gate makes the second question explicit.
uniauth:
approval:
enabled: true
require-for: [ LDAP, OAUTH2, SAML ] (1)
pending-page: /pending
| 1 | Which mechanisms are held, and already the default. Internal accounts are left out because writing one into configuration is already an approval. |
A held principal authenticates successfully and then lands on the pending page until an administrator decides. Approving admits them on their next request — no second sign-in.
1. Three decisions worth not re-litigating
It sits in authorization, not authentication. ApprovalAuthorizationManager replaces
.authenticated() in the chain. Failing the login instead would report "pending" as a
credentials failure, which is both misleading and a disclosure: it confirms a correct guess
to whoever is guessing.
First sighting is recorded in the manager, not a success handler. Four mechanisms would mean four success handlers; every request passes authorization exactly once.
Keys are (provider, principal). Approving alice from the internal store must not admit
alice from Google.
2. Who is waiting
The key is stable and unreadable — for an OIDC login it is the sub claim, a long number.
Approving on that alone is deciding about a stranger, so PrincipalIdentity is captured at
first sighting and carried alongside:
|
a human name, or absent |
|
the address claimed, or absent |
|
whether the provider says it verified that address — or absent, when it says nothing |
|
the stable identifier the key is built from |
Three states for verification, never collapsed into two. An unverified address is a claim somebody typed, not something the provider stands behind; treating it as identity means trusting whoever asserted it rather than the provider. "The provider said nothing" is a third answer again, and pretending otherwise would make the queue look more informative than it is.
It is captured at first sighting rather than read live because the queue has to render for a principal who is no longer signed in.
3. Approving grants roles
Approval is not only "may this principal in". A federated sign-in arrives with
OIDC_USER and a SCOPE_* per scope and no ROLE_ at all, so without this an approved
user clears the gate, reaches the application, and is refused by every hasRole(…) rule
in it — while an internal account has had roles all along, from configuration. The two
mechanisms were not equivalent in what they could express, and the federated one needs it
more.
So the decision carries them:
void decide(ApprovalKey key, ApprovalStatus outcome, String approver, List<String> roles);
uniauth:
approval:
default-roles: [ USER ] # granted when an approver approves without naming any
The ROLE_ prefix is added if absent, so an approver types ADMIN and hasRole("ADMIN")
matches. They are granted on the request after the decision, which is the same promise the
gate itself makes — approval takes effect without a second sign-in.
This is why a GrantedAuthoritiesMapper bean cannot do the job. It receives the
authorities and not the principal, and roles are stored per principal, so it has nothing to
look them up by. The authorities are attached by a filter that has both.
|
The authentication is rebuilt to carry them, keeping its concrete type: that type is how
MechanismResolver knows which provider answered, so flattening every principal into a
UsernamePasswordAuthenticationToken would quietly break the approval key on the next
request. A type UniAuth does not recognise is left exactly as it was.
4. The store
ApprovalStore is an SPI, and the gate is the only stateful thing in the library. That is
deliberate: owning a table would force Spring Data, a schema and migrations onto every
consumer.
public interface ApprovalStore {
ApprovalStatus statusOf(ApprovalKey key);
ApprovalRecord recordPending(ApprovalKey key, PrincipalIdentity identity, AuthProviderType mechanism);
List<ApprovalRecord> pending();
Optional<ApprovalRecord> find(ApprovalKey key);
void decide(ApprovalKey key, ApprovalStatus outcome, String approver);
void remove(ApprovalKey key);
}
InMemoryApprovalStore is the default and is not production-fit. It empties on
restart, taking every approval with it, and it is per-instance — two replicas do not agree.
Supply your own implementation for anything real.
|
5. Two things that bite if changed carelessly
-
The pending page is added to the permitted matchers. Without that, the redirect loops.
-
PendingApprovalAccessDeniedHandleronly redirects requests the manager flagged, so a genuine 403 still looks like one. A denied principal gets 403, not the waiting page.