You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Host side of cross-tenant delegated administration for partner advisors.
Design and plan live in the angular-my-microting-plugin repo: docs/superpowers/specs/2026-10-23-partner-tenant-delegated-access-design.md and docs/superpowers/plans/2026-10-23-delegated-access-host.md.
Why
Partner advisory firms manage many customer tenants and their staff need access to all of
them. Today an account is created and linked by hand per tenant, and for partner admins it
does not work at all — the only link candidate offered is the partner's own organisation,
which never has a running tenant.
An earlier design proposed replicating the portal's PBKDF2 PasswordHash into every
tenant. That was withdrawn: no vendor does hash copying as ongoing sync (every
hash-import feature in Auth0, Cognito, Okta and Keycloak is a one-time migration with a
documented end state), Identity silently rehashes and then downgrades the pushed copy
across a mixed-version fleet, and it would regress on the plaintext fan-out already
shipped. This branch instead follows the pattern Dynamics 365 Business Central, Azure
Lighthouse and Entra GDAP all use.
What this adds
A second, strictly additive sign-in path on every tenant:
The portal signs a short-lived, single-audience RS256 assertion.
The browser carries it in a URL fragment to /auth/delegated.
The tenant validates it against the portal's published JWKS.
On first use it provisions a credential-less pseudonymous local principal — null PasswordHash, a synthetic non-deliverable address, linked under its own provider
namespace — and puts it in the eForm users group from a fixed server-side template.
The tenant issues its own JWT, exactly as a password login does.
Password login is untouched.AuthService.cs, UserService.cs and ClaimsTransformer.cs are byte-identical, the login-path fence stays green, and the frozen
guard block's hash is unchanged. Tenant-native users keep their passwords and their direct
login unconditionally.
Also included, and valuable on its own: every password-setting path now refuses a
delegated principal, so an advisor account cannot acquire a credential through admin
change-password or the forgot/reset-password routes.
The contract the portal must match
Redirect to https://{customerNo}.microting.com/auth/delegated#assertion=<jwt> —
fragment, never the query string.
RS256, asymmetric, verified against the portal's JWKS. alg=none and HMAC substitution
are both rejected.
Claims: sub (required), jti (required, single-use), role_template
(required, must be exactly "advisor" — unknown templates are refused), name (optional, audit only).
aud = the tenant's customerNo. Single-audience enforced: a token minted for one tenant
is rejected by another.
IUserService.AddPasswordAsync is ungated. It is a published BasePn interface
member calling userManager.AddPasswordAsync with no delegated-principal check. There is
no in-repo caller, but a plugin could reach it, so the credential-less guarantee is
airtight for host code and not for plugin code. UserService.cs is byte-frozen by the CI
fence, so closing this needs a deliberate fence amendment in its own PR.
Login CSRF is only half closed.Closed by 25dd5a8ec, added to this PR. The flow
is now tenant-initiated: /auth/delegated/start generates a state with crypto.getRandomValues, stores it in sessionStorage on the tenant's own origin, and
bounces to the portal; the delegated route compares the assertion's state claim against
the stored value before posting anything, and clears it either way. A link carrying an
attacker's assertion arrives at a browser holding no state, and is refused — the case is
pinned by delegated route refuses when no state is stored at all. The validator now
requires the state claim exactly as it requires jti and role_template. The customer
number comes from the tenant's own hostname, never from a query parameter, so the start
route cannot be steered.
Replay and JWKS fetch gating are process-local. Correct while tenants stay replicaCount: 1, which the helm chart is. Scaling a tenant out needs a shared store.
The Enabled kill switch is live only when flipped through the settings mechanism — EfConfigurationProvider reloads on a settings write. A raw SQL edit straight into the
table still needs a pod restart. Worth saying in operator docs.
No admin-facing way to see or remove a delegated link. The existing link UI filters to
the P1 provider. Revocation today is strip-the-role or deactivate.
Idle expiry of stale advisor principals is not implemented. It needs somewhere to
persist a last-sign-in time, and the host owns no migrations — BaseDbContext ships
inside a NuGet package — so it was deliberately left out rather than decided mid-stream.
The rows it would remove are credential-less and inert.
The anonymous endpoint does RSA verification per request with no rate limit. The JWKS
fetch is gated and coalesced; the CPU is not.
Verification
Six tasks, each implemented and reviewed independently, then a whole-branch review which
returned fit to merge. Integration tests were authored locally but executed in CI — the
development machine has no MariaDB. CI caught two defects local review did not: a too-broad
password-refusal predicate that broke admin password-setting, and a first-sign-in race where
the link was written before the role, so a concurrent adopter found a role-less principal
and was refused.
Adds the security core of partner-federated sign-in: DelegatedAssertionValidator
turns a raw, attacker-chosen string into a DelegatedAssertion only when signature,
algorithm, issuer, audience, lifetime and replay all pass.
- Algorithm pinned to RS256 three ways: the header is checked before anything else
looks at the token, TokenValidationParameters.ValidAlgorithms says the same to the
library, and the candidate key set is filtered to asymmetric keys. alg=none and
HMAC-with-the-public-key both die at the door, before the JWKS is even fetched.
- Single audience, exactly: the library's any-match audience check would accept an
assertion minted for {1043, 1054} at both tenants.
- Replay is checked LAST and atomically, so an unauthenticated caller cannot burn a
jti it does not hold, and two concurrent presentations of one assertion sign in once.
- JWKS cached for 10 minutes, refetched at most once per validation on an unknown kid,
with a 30s cooldown so a flood of unknown kids cannot amplify against the portal.
An unreachable, slow, oversized, non-2xx or unparseable JWKS fails the validation
closed without throwing.
- Every refusal returns one uniform message; the reason is logged, never returned, so
the public endpoint is not an oracle for which part of a forgery to fix next.
No DI registration here: Startup.cs is Task 4's, and the login-path fence stays clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8CRdEseXEmtevf5bPHHL6
Review round 1 on Task 3.
Important 1 — the refetch cooldown was neither race-safe nor set on failure.
TryGetValue(cooldown) and the fetch were not serialised, so N *concurrent* unknown
kids produced N outbound requests: the 30s cap only ever bound sequential callers,
which is all the old tests exercised. And because failures were not cached and the
cooldown was written only on success, an unreachable portal cost one outbound
attempt — each holding a 10s timeout — per inbound token, an amplification any
anonymous caller could drive. Now every outbound attempt, cold start and forced
refetch alike, goes through one process-wide gate per JWKS endpoint, so concurrent
callers reuse the winner's result; and the cooldown is written BEFORE the attempt,
so a failure costs the next caller its attempt exactly as a success would. It is
cleared again only after a successful cold start, so a genuine key rotation is still
picked up immediately rather than after a cooldown.
Important 2 — role_template is now required. The claim names are this repository's
unilateral invention and the portal that mints them lives elsewhere, so a mismatch
degraded silently to "" — and Task 5 trusts this result completely, which would turn
that into some default permission set on a customer's tenant. It now fails exactly as
an absent jti does. `name` stays optional: audit-only, never an identifier.
Minor — jti, kid, alg and sub are attacker-chosen and were logged raw, so a newline
forged a whole extra entry in any line-oriented sink. Control characters are replaced
and the value truncated before it reaches a log line.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8CRdEseXEmtevf5bPHHL6
Resolve-or-provision the advisor's local principal from a validated portal
assertion, then mint this tenant's own session for it.
The principal is created with Identity's no-password overload, so PasswordHash
stays null, and it is linked under ExternalLoginProviders.MyMicrotingDelegated,
which every password-setting path refuses and P3's fan-out cannot see. The only
resolution key is (delegated provider, sub): a tenant-native account that holds
the derived address, or a person who already holds a P1 link on this tenant, is
never adopted — the delegated principal is created alongside it. The row carries
a one-way digest of sub plus a non-deliverable .invalid address, so the
advisor's real address never reaches the customer's database; the display name
is stored for audit attribution only.
The role is resolved server-side from the asserted template, which cannot name
an administrative role and is refused if this tenant does not know it. User Id 1
and the admin role are refused on the resolved principal as well, and the
template role is added once at creation so stripping it stays a working
revocation. Every sign-in is logged at information level with the local user id,
sub, jti and whether the principal was created or reused, each attacker-chosen
value sanitised through Task 3's ForLog (now internal, so there is one copy).
Two concurrent first sign-ins are settled by the UserLogins primary key rather
than a lock: the loser discards its pending insert, deletes its own orphan row
and adopts the winner. A provisioning failure costs the advisor a fresh
assertion, because the validator burns the jti before this service is called;
that is correct and is not worked around.
IAuthService gains GenerateToken, which AuthService already implements as
public, so delegated sign-in and password login share one minting
implementation. AuthService.cs, UserService.cs and ClaimsTransformer.cs are
untouched and the CI login-path fence is clean, frozen hash unchanged.
NoRuntimeBindingSourceTests' allowlist gains this file: it is the delegated
sign-in path, which creates and reads links but can never set a credential.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8CRdEseXEmtevf5bPHHL6
CI's real MariaDB refused the loser of a concurrent first sign-in. The cause is
an ordering one, not a recovery one: the link is the discovery key, and it was
written BEFORE the role, so a request that adopted the other's principal in the
gap between those two writes read an empty role list and refused — the same
refusal a customer who stripped the template role should get. Two tabs, or two
advisors arriving together, denied one of them.
The role is now written first and the link last, so a principal becomes
discoverable only once it is complete, and any reader that sees the link sees
the role that committed before it. No lock, no retry, no second read. If the
role write fails the half-built row is deleted, because without a link nothing
could ever resolve it.
Two more, both on the same path. AddLoginAsync re-reads the key before writing,
so a lost race usually arrives as a LoginAlreadyAssociated IdentityResult rather
than a constraint violation; that shape was logged at Error, which would page
someone for a working sign-in, and now shares the information-level line with
the violation path. And a link refusal whose re-read names no holder now retries
within the existing bound instead of refusing: the holder that caused it no
longer exists.
The concurrency test registers every row matching the derived address for
cleanup, before its first assert, so a failing assert cannot leak a row into a
database that ClearDb() does not truncate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8CRdEseXEmtevf5bPHHL6
Closes the login-CSRF item left open by #8168. Until now the flow was
IdP-initiated: an assertion was genuine, but nothing tied it to the browser
that received it, so anyone who could have one minted for their own advisor
account on their own customer could mail the link to a victim and have the
victim's browser sign itself in as somebody else.
The tenant now asks first. A new /auth/delegated/start generates a state with
crypto.getRandomValues, keeps it in sessionStorage on the tenant's own origin,
and sends only its copy to the portal's bridge page. The portal echoes it into
the assertion, and /auth/delegated compares the two before it posts anything.
A browser holding no state refuses, which is the case the whole change exists
for.
Nothing about the start route's destination is steerable: the portal host is a
constant and the customerNo is the first label of the tenant's own hostname, so
a ?customerNo=, ?issuer= or ?redirect_uri= is ignored entirely. A hostname with
no numeric first label refuses rather than guesses. The state is taken, and so
cleared, before every branch of the landing route, so no attempt can leave a
usable one behind; every refusal shows the one existing message and says
nothing about which condition failed.
Server-side, DelegatedAssertion carries State and the validator requires the
claim exactly as it requires jti and role_template. The comparison can only
happen in the browser that holds the value, but the endpoint is public, so an
assertion carrying no state at all dies at the validator rather than relying on
a client to notice.
main.ts is untouched: captureDelegatedAssertion still runs before Sentry.init.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8CRdEseXEmtevf5bPHHL6
… from telemetry
Review round 1 on the tenant-initiated flow. Two gaps, both in what the tests
and the telemetry hooks cover rather than in the shipped behaviour.
Nothing pinned the CSPRNG, which is the one load-bearing property of the whole
defence. Replacing crypto.getRandomValues with a Math.random byte fill left all
25 specs green: the charset and 200-draw uniqueness tests are satisfied by
Math.random, and even by a counter, so a well-meaning "add a fallback so old
browsers work" refactor would have shipped silently. Three specs now pin it:
the CSPRNG must be called, with a 32-byte buffer, and the output must be
exactly what those bytes map to, so no second source can be mixed in; b and
b+64 must land on the same character, which only holds while the alphabet is
exactly 64 symbols and the mapping unbiased; and a browser with no CSPRNG at
all must get null rather than a guessable state. That last branch had no test
and is now covered twice, in the helper and end to end through the start route,
by removing the function rather than stubbing it — a stub is still a function
and would take the happy path. All four fail against the Math.random mutation.
The Sentry URL scrub matched assertion= only. The same bundle also serves the
portal origin, whose authorize bridge page carries ?state= in the address bar,
and replaysOnErrorSampleRate: 1.0 ships the buffered replay, URL bar included,
on any error. Unlike OAuth the portal echoes whatever state the caller supplies
and mints for the caller's own advisor identity, so someone reading a state in
flight could mint an assertion echoing it and race the victim's round trip. One
token in the regex closes it, in the same file an earlier review already
hardened for this leak class. customerNo stays legible, so a scrubbed replay is
still readable.
Also notes why decodeBase64Url does not reuse JwtHelper: it must answer null
for arbitrary attacker-supplied input rather than throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8CRdEseXEmtevf5bPHHL6
Provisioning adds an Identity role but no security-group membership. ClaimsService.GetUserClaims (lines 129–145) derives non-admin host and plugin permissions from SecurityGroupUsers and GroupPermissions, so a new advisor gets a valid session without the permissions needed to administer the customer. AdminService.Create adds this membership separately from the role. Map the advisor template to a server-selected security group and persist membership before publishing the login link, with cleanup if provisioning fails. Verify the resulting permissions using the real claims service rather than the empty substitute in the integration tests.
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
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.
Host side of cross-tenant delegated administration for partner advisors.
Design and plan live in the
angular-my-microting-pluginrepo:docs/superpowers/specs/2026-10-23-partner-tenant-delegated-access-design.mdanddocs/superpowers/plans/2026-10-23-delegated-access-host.md.Why
Partner advisory firms manage many customer tenants and their staff need access to all of
them. Today an account is created and linked by hand per tenant, and for partner admins it
does not work at all — the only link candidate offered is the partner's own organisation,
which never has a running tenant.
An earlier design proposed replicating the portal's PBKDF2
PasswordHashinto everytenant. That was withdrawn: no vendor does hash copying as ongoing sync (every
hash-import feature in Auth0, Cognito, Okta and Keycloak is a one-time migration with a
documented end state), Identity silently rehashes and then downgrades the pushed copy
across a mixed-version fleet, and it would regress on the plaintext fan-out already
shipped. This branch instead follows the pattern Dynamics 365 Business Central, Azure
Lighthouse and Entra GDAP all use.
What this adds
A second, strictly additive sign-in path on every tenant:
/auth/delegated.PasswordHash, a synthetic non-deliverable address, linked under its own providernamespace — and puts it in the eForm users group from a fixed server-side template.
Password login is untouched.
AuthService.cs,UserService.csandClaimsTransformer.csare byte-identical, the login-path fence stays green, and the frozenguard block's hash is unchanged. Tenant-native users keep their passwords and their direct
login unconditionally.
Also included, and valuable on its own: every password-setting path now refuses a
delegated principal, so an advisor account cannot acquire a credential through admin
change-password or the forgot/reset-password routes.
The contract the portal must match
https://{customerNo}.microting.com/auth/delegated#assertion=<jwt>—fragment, never the query string.
alg=noneand HMAC substitutionare both rejected.
sub(required),jti(required, single-use),role_template(required, must be exactly
"advisor"— unknown templates are refused),name(optional, audit only).aud= the tenant's customerNo. Single-audience enforced: a token minted for one tenantis rejected by another.
DelegatedAccess:Enabled|Issuer|JwksUri|Audience|ClockSkewSeconds.Known gaps, stated deliberately
IUserService.AddPasswordAsyncis ungated. It is a publishedBasePninterfacemember calling
userManager.AddPasswordAsyncwith no delegated-principal check. There isno in-repo caller, but a plugin could reach it, so the credential-less guarantee is
airtight for host code and not for plugin code.
UserService.csis byte-frozen by the CIfence, so closing this needs a deliberate fence amendment in its own PR.
Login CSRF is only half closed.Closed by25dd5a8ec, added to this PR. The flowis now tenant-initiated:
/auth/delegated/startgenerates astatewithcrypto.getRandomValues, stores it insessionStorageon the tenant's own origin, andbounces to the portal; the
delegatedroute compares the assertion'sstateclaim againstthe stored value before posting anything, and clears it either way. A link carrying an
attacker's assertion arrives at a browser holding no state, and is refused — the case is
pinned by
delegated route refuses when no state is stored at all. The validator nowrequires the
stateclaim exactly as it requiresjtiandrole_template. The customernumber comes from the tenant's own hostname, never from a query parameter, so the start
route cannot be steered.
replicaCount: 1, which the helm chart is. Scaling a tenant out needs a shared store.Enabledkill switch is live only when flipped through the settings mechanism —EfConfigurationProviderreloads on a settings write. A raw SQL edit straight into thetable still needs a pod restart. Worth saying in operator docs.
the P1 provider. Revocation today is strip-the-role or deactivate.
persist a last-sign-in time, and the host owns no migrations —
BaseDbContextshipsinside a NuGet package — so it was deliberately left out rather than decided mid-stream.
The rows it would remove are credential-less and inert.
fetch is gated and coalesced; the CPU is not.
Verification
Six tasks, each implemented and reviewed independently, then a whole-branch review which
returned fit to merge. Integration tests were authored locally but executed in CI — the
development machine has no MariaDB. CI caught two defects local review did not: a too-broad
password-refusal predicate that broke admin password-setting, and a first-sign-in race where
the link was written before the role, so a concurrent adopter found a role-less principal
and was refused.
🤖 Generated with Claude Code
https://claude.ai/code/session_01P8CRdEseXEmtevf5bPHHL6