Skip to content

Azure cloud discovery onboarding: consent, Reader, and a certificate credential - #53

Open
amansingh39 wants to merge 22 commits into
authsec-stagingfrom
iga-azure-onboarding
Open

amansingh39 wants to merge 22 commits into
authsec-stagingfrom
iga-azure-onboarding

Conversation

@amansingh39

Copy link
Copy Markdown

Changes

Azure cloud-discovery onboarding: everything needed to get a customer's Entra tenant
to the point where AuthSec holds a working application-only token and both Azure
authorization planes have been verified. 18 endpoints under /api/azure, 40 files,
13,760 insertions, no deletions.

AuthSec is self-hosted, so the customer creates the app registration in their own tenant
and submits it through the API. Nothing here assumes AuthSec runs on Azure, and nothing
assumes one global application.

The idea the whole feature rests on

Azure has two separate authorization systems, and onboarding is complete only when both
are satisfied:

granted by gives the app
Entra consent (Microsoft Graph) an administrator accepting a consent screen the directory: users, groups, service principals, role assignments
Azure RBAC (ARM) assigning the Reader role the subscriptions: resources, resource groups, key vaults

Consent is not access. A tenant can grant every Graph permission and the application
still sees zero subscriptions. Most of the complexity below is keeping these two apart in
the code and, more importantly, in what an operator is told.

What is in the PR

Per-workspace application (services/azure_app_config.go, models/, repository/,
migrations/017) — a workspace submits its own client id, tenant and credential via
POST /api/azure/config; the public fields land in azure_app_config, the credential in
Vault. AZURE_CLIENT_ID / AZURE_CLIENT_SECRET / AZURE_REDIRECT_URI remain as an
optional fallback for single-tenant deployments.

Certificate credentials (internal/azureonboard/credential.go) — AuthSec generates
the RSA key pair, keeps the private half in Vault and hands back only the certificate to
upload. A client secret travels to Microsoft on every token request; a certificate signs a
short-lived assertion and the key never moves. There is deliberately no field for
submitting your own key
: pasting one sends it down exactly the path the certificate
exists to avoid.

Two-leg consent chain (internal/azureonboard/oauth.go) — sign in, then send the
administrator to /v2.0/adminconsent for the tenant the token names.
prompt=admin_consent is a v1.0 parameter and v2.0 rejects it with AADSTS901001;
prompt=consent is not a substitute, because one /authorize call consents one resource
and this one asks for ARM.

Reader assignment (services/azure_onboarding.go) — per subscription, or once at the
tenant root management group so subscriptions created later are covered. Sent as the
signed-in operator, so it succeeds exactly when that person could have done it by hand;
when it cannot, the response carries ready-made az / PowerShell / ARM-template / portal
fallbacks rather than leaving the flow half-done. Root scope can require briefly raising
the operator's own privilege — opt-in behind AZURE_ALLOW_ROOT_ELEVATION, and the
privilege is always given back.

One-sign-in setup (services/azure_auto_setup.go) — consent, find the tenant, assign
Reader, verify both planes, behind a status endpoint. Assumes ONE person holds both
privileges, which is the on-prem case; the step-by-step endpoints remain for when they are
different people.

Retry transport (internal/azureonboard/retry.go) — 429/502/503/504 in one place
rather than at nine call sites. Not 500: a write may have applied.

Named failures (internal/azureonboard/diagnose.go) — Diagnose turns Microsoft's
refusal into a title, a fix and whose problem it is, beside the raw message rather than
instead of it. Two failures look almost identical and are fixed in different blades of the
portal:

401 invalid_client               the credential is wrong        -> Certificates & secrets
403 Authorization_RequestDenied  the credential is RIGHT and    -> API permissions
                                 nothing was granted to it

An unrecognised error returns nil. Guessing means telling someone confidently to change
the wrong thing.

No new deployment secret — the sign-in cookie key is derived as
HMAC(JWT_SECRET, "authsec/azure-session-cookie/v1"). An earlier revision introduced
SESSION_SECRET, which nothing else in the codebase read and which an operator was only
told about at the very end of an otherwise complete setup. It remains an optional override
for anyone who wants the two blast radii separate.

Docsdocs/flows/azure-onboarding.md.

Bugs found and fixed in review

An audit of this code (8 dimensions, adversarially verified) produced 55 confirmed
findings. The nine that were security- or correctness-critical are fixed here:

  • SSRF through the ARM assignment scope. ARMBase + scope was concatenated with no
    validation, and "https://management.azure.com" + "@10.0.0.7" parses with host
    10.0.0.7 and the real ARM host demoted to userinfo — so a caller could send a PUT with
    the operator's bearer token to any address the deployment can route to. Now validated at
    the service boundary, and AssignRole re-checks the parsed host before sending.
  • A workspace could silently act as the wrong application. A bind failure returned a
    service copy that still held the deployment's clientID, redirectURI and Microsoft
    client, and Ready() only surfaced the problem when something else was also missing.
    With AZURE_* set, Ready() returned nil, and a customer's administrator could be
    sent to grant admin consent to a different application in their own directory,
    recorded as correct.
  • An elevation that applied was never given back. An ambiguous ElevateAccess failure
    (timeout, reset, 5xx after the write) returned before the removal defer was registered,
    leaving root User Access Administrator live while the status said elevated: false.
  • Unauthenticated routes leaked internals. /login and /callback returned the Vault
    path and the raw secrets-store error. The detail now goes to the log; the caller gets
    the fact.
  • Every unclassified error was a 400 — a Postgres or Vault outage reported as a bad
    request. Deployment failures now answer 500 with fault: "authsec".
  • A save replaced the private key. Every save demanded a credential, and the only
    certificate this accepts is one it generates, so correcting a redirect URI minted a new
    key pair and orphaned the certificate already uploaded — AADSTS700027 quoting a
    thumbprint nobody had seen. keepCredential fixes it.
  • Thumbprint encoding drift — base64url on POST, hex on GET, under one field name, so
    the value shown after generating matched nothing in the portal. Both are hex now.
  • Two definitions of the required permission set — collapsed to one.
  • Sovereign clouds were brokenARMBase is env-configurable but the ARM scopes
    hardcoded management.azure.com, so a Gov/China deployment asked its own authority for
    a public-cloud resource.

The remaining 46 findings are documentation drift, dead code and test-coverage gaps. None
is a security issue. The largest is that AssignRole — the only write this codebase makes
into a customer's Azure — has no test coverage; worth a follow-up PR.


Testing

  • Unit tests pass (go test -short ./tests/unit/)
  • go vet ./... clean
  • Manual testing (described below)
  • Add label run-integration to this PR to trigger integration tests in CI (requires a live DB)

17 test files, 123 test functions, 4,682 lines. All green: 45 in
internal/azureonboard, 64 in services/azure_*, 7 in controllers/platform.

These caught real defects rather than confirming intent: a panic on the first retried POST
(nil GetBody), a bare subscription GUID reaching ARM as a scope, and — during this
review — a bug in the new scope validator itself, which trimmed whitespace before matching
and so accepted a trailing newline.

Verified against a real Azure tenant

Not a simulator. The full flow was run end to end against a live tenant with the
certificate credential, and the discovery surface was then probed directly:

Graph : users 4 · groups 1 · service principals 254 · app registrations 8
        managed identities 5 · directory roles 32 (24 members)
        oauth2 grants 6 · devices 4 · audit logs 157 · organization 1
ARM   : subscriptions 5 · resource groups 3 · resources 50
        role assignments 88 (ServicePrincipal 52, User 36)
        user-assigned identities 1 · key vaults 1 · policy assignments 15
        management groups 1

29 of 30 reads succeed. The one failure is sign-in logs -> 403 Authentication_RequestFromNonPremiumTenantOrB2CTenantAuditLog.Read.All is in the
token, but the endpoint requires an Entra ID P1/P2 licence. A tenant licensing limit,
not a permission gap, and reported as such rather than as a missing consent.

Tenant-root Reader was verified to cover all five subscriptions, and the Graph managed
identities join correctly to their ARM role assignments (13 assignments held by managed
identities) — the join the product exists to surface.

Also verified live with SESSION_SECRET unset and no AZURE_* variables at all: the
workspace reports ready: true, missing: [], running entirely off the database and
Vault.

Not in this PR

Azure discovery does not exist yet. This PR is onboarding only: it gets a working
token and proves both planes are satisfied. Nothing reads identities or resources out of
Azure into cloud_identity / cloud_resource, and nothing writes a cloud_connector row
with provider azure — an onboarded tenant lands only in azure_connectors, confirmed
against the live database. The probe above shows the data is all reachable; the reader is
the next PR.


Checklist

  • Code follows the project's style guidelines
  • Self-reviewed the diff
  • No secrets or credentials committed — the credential is a Vault path, never a
    value, in the column, the response and the logs. CurrentCertificatePEM returns
    only CERTIFICATE blocks and a test asserts the private key can never come back.
  • Updated documentation if needed — docs/flows/azure-onboarding.md, and
    .env.example corrected: the Azure block described the pre-API model (one
    application baked into the deployment), AZURE_SIGNIN_TENANT documented a default
    of /organizations where the code reads common, AZURE_GRAPH_ENDPOINT was
    missing, and AZURE_ALLOW_ROOT_ELEVATION was undocumented despite gating a
    privilege raise.
  • Added/updated tests for new behaviour

Migration note

migrations/master/001_bootstrap.sql gains azure_app_config (38 lines, additive,
IF NOT EXISTS, zero deletions). It existed in 017_azure_app_config.sql but had never
been mirrored into the bootstrap, so a database built from the bootstrap had no table and
POST /api/azure/config — the first step of onboarding — failed on a fresh install while
working everywhere it had been migrated. Verified by running the bootstrap against an
empty database.

Reviewer notes

Worth a careful look, in order:

  1. internal/azureonboard/credential.go — the client assertion and what is deliberately
    not offered
  2. services/azure_onboarding.go, assignReaderTenantWide — the elevation is raised and
    returned; every exit path must give it back
  3. internal/azureonboard/oauth.go, ValidateARMScope — the SSRF fix
  4. services/azure_app_config.go, ForWorkspace — the wrong-application fix

Facts established live against a real tenant, in case they save someone the same
afternoon: admin consent with .default replaces a tenant's existing delegated grants
rather than adding to them; Entra rotates the refresh token on every redemption, so a loop
that redeems N+1 times invalidates its own earlier tokens; Graph returns an X509
credential's customKeyIdentifier as uppercase hex, not the base64 the documentation
claims; and Policy.Read.ConditionalAccess does not work for
/identity/conditionalAccess/policies — it needs Policy.Read.All.

Onboarding only -- consent an Entra tenant and prove whether that consent
bought ARM read access. No identity scan, no permission extraction, no access
graph; those cannot start until a tenant reaches arm_reader_ok.

THE FACT THE DESIGN FOLLOWS FROM. Consent and authorisation are separate grants
in Azure, made by different people at different times. Admin consent creates the
service principal in the customer's directory and grants Graph application
permissions; an Azure RBAC role assignment is what lets that principal read
anything. An application can be fully consented and still read nothing at all.
Recording consent as if it were access is the mistake the split exists to
prevent, which is why arm_reader_ok is a separate column that consent never
sets.

Layering follows the AWS connector: internal/azureonboard holds provider logic
with no database, no gin and no workspace concept, reaching Microsoft through an
interface so the service layer can be driven by a fake.

Notable decisions:

  - One multi-tenant App Registration, never one per tenant. The per-tenant
    service principal is created by consent, and its object id is read from the
    oid claim of an app-only token rather than from Graph /servicePrincipals --
    which keeps the promise that this flow never touches those endpoints.

  - azure_oauth_state is a one-shot row redeemed with DELETE ... RETURNING, so
    reading a state and spending it are the same statement. A literal state
    string cannot be validated; the row is what makes a callback authorised, and
    it is the only place the workspace and consented tenant are read from.

  - arm_reader_ok requires ARM to return at least one subscription, not merely
    to answer. ARM replies 200 with an empty list when the token is valid but no
    role assignment exists anywhere, and that was observed reporting a tenant as
    fully onboarded while the application could read nothing.

  - The delegated token goes to the secrets store, never to a cookie and never
    to a process map: a cookie would put a live ARM bearer token in a browser,
    and a map would drop every sign-in on restart.

Verified against a real Entra tenant: sign-in, admin consent, app-only token
acquisition and the ARM probe all exercised end to end. Migration 015 applies
cleanly and 001_bootstrap.sql carries the same end state.
…ator's behalf

Six routes: login, callback, tenants, consent, validate-arm, connectors, plus
reader-setup and assign-reader.

WHY /api/azure AND NOT /authsec/discovery/azure. The callback path is fixed by
the redirect URI registered on the Entra application; Microsoft will not
redirect anywhere else. The rest of the flow is grouped with it so it reads as
one thing.

WHY TWO ROUTES ARE UNAUTHENTICATED. login and callback are top-level browser
navigations, and a redirect arriving from Microsoft carries no bearer token, so
AuthMiddleware would reject every callback. They are rate limited and authorised
by the one-shot state row instead -- the same reasoning the GitHub webhook route
already uses. The remaining routes are ordinary fetch calls and authenticate and
RBAC check exactly like the AWS discovery routes.

REMOVING THE MANUAL AZURE WORK. Assigning ARM Reader is the one step no
application can perform for itself; Azure offers no mechanism, deliberately.
What it can do is ask ARM on behalf of a human who already holds Owner or User
Access Administrator, which is exactly what that person would be doing through
the portal, minus the clicking. assign-reader does that with the operator's own
delegated token, and falls back to ready-made az / PowerShell / ARM template /
portal instructions when they lack the privilege -- nothing is left half done.

The assignment name is a deterministic UUIDv5 over (scope, principal, role) so a
retry addresses the same object rather than creating a duplicate;
RoleAssignmentExists counts as success because the grant is present either way;
principalType is set so ARM waits for directory replication instead of refusing
a freshly consented principal.

Two smaller pieces earn their place:

  - POST /consent answers JSON when the client asks for it. A browser console
    cannot use the 302: the endpoint needs a bearer token so it cannot be a
    plain navigation, and fetch follows the redirect itself, sending an XHR to
    login.microsoftonline.com where the absence of CORS headers fails the
    request before the consent page is ever shown.

  - AZURE_SIGNIN_TENANT pins sign-in to one tenant's authority. The default,
    organizations, rejects personal Microsoft accounts, which is usually right
    -- but an account that signed up for Azure with a personal address
    administers its directory as an external guest and is turned away despite
    genuinely administering a tenant.

Sovereign clouds are reachable through AZURE_AUTHORITY_HOST and
AZURE_ARM_ENDPOINT; both default to the public cloud.

Still open, and documented: Graph authorisation is never verified, so a tenant
whose application permissions were never declared is recorded as consented while
holding none. Subscriptions are listed and discarded rather than persisted, so
the tenant-to-subscription relationship a later access graph needs does not
exist yet.
Closes the two places onboarding claimed something it had never checked, plus
the audit gap and the cookie flag found alongside them.

GRAPH AUTHORISATION WAS NEVER VERIFIED. Admin consent wrote consented_at and the
flow moved on; nothing asked Microsoft whether a single permission had been
granted. Against a real tenant that produced a connector recorded as consented
whose app-only token carried no roles claim at all, with every Graph endpoint
answering Authorization_RequestDenied. The cause is that .default consent grants
only what the application declares, and an application declaring nothing is
consented successfully and holds nothing.

validate-graph is the counterpart to validate-arm, and reads the roles claim of
an app-only token rather than probing an endpoint. That works even when nothing
was granted, needs no permission of its own, costs no extra call, and names the
individual permissions that are missing -- where a probe only shows that one
endpoint happened to answer. Against the real tenant it now reports graph_ok
false, lists all four required permissions as missing, and explains that the
registration declares none and that the Type must be Application rather than
Delegated, which is the mistake that produces this state.

SUBSCRIPTIONS ARE NOW ROWS. The ARM probe listed them and threw them away,
keeping one boolean for the whole tenant, so Reader on two of five subscriptions
reported true -- an all-clear that was not earned, the same class of error as
the empty-list false positive fixed earlier. Reader is assigned per scope, so
coverage is a per-subscription fact and is now stored as one.

The composite foreign key to the connector is as much the point as the rows: a
subscription cannot exist without its tenant, so a subscription id can never be
read without the tenant context that says whose it is and which credential may
reach it. It is also what the eventual access graph attaches resources, managed
identities and role assignments to.

Two smaller fixes found while auditing:

  - assign-reader is the only state-changing action this feature performs inside
    another organisation's tenant and left no audit record. Both outcomes are
    audited now; the refusal is the more interesting one.

  - The session cookie took its Secure flag from Request.TLS, which is nil
    behind a terminating proxy -- the normal deployment -- so a cookie
    addressing a live delegated Azure token shipped without it. Now uses the
    same X-Forwarded-Proto test middlewares/security.go already applies to HSTS.

Schema goes in 016 rather than amending 015, which has already been pushed;
001_bootstrap.sql carries the same end state. Verified end to end against a real
Entra tenant.
Consent settles whether a permission is granted. It does not settle whether
the data is reachable: Microsoft gates some of it on the tenant's LICENCE.

Found against the real tenant immediately after the four application
permissions were granted and validate-graph went green. Ten of the eleven
endpoints discovery needs answered 200. /auditLogs/signIns answered 403
Authentication_RequestFromNonPremiumTenantOrB2CTenant -- "tenant is not a B2C
tenant and doesn't have premium license" -- while holding AuditLog.Read.All.
Sign-in logs need Entra ID P1 or P2. directoryAudits, the other half of the same
permission, works on the free tier.

Discovery has to know this before it runs. Without it the sign-in-activity
reader fails on every free-tier tenant in a way that reads as a code bug and is
not, and "last used" becomes silently unknown with nothing recording why.

validate-graph now probes the capabilities consent alone cannot settle and
reports them separately from graph_ok, flagging the licence gate as such:

  granted_roles : all four
  graph_ok      : true
  capabilities  : signin_activity available=false license_gated=true
                  "the tenant has no Entra ID P1/P2 licence, so sign-in logs
                   are withheld even though AuditLog.Read.All is granted"

Kept out of graph_ok deliberately. A licence gate is a customer purchasing
decision, not a missing consent and not an AuthSec fault, and collapsing the
two would send an operator to re-run consent that already succeeded.

Probed only when something was granted -- with nothing granted every capability
fails for the obvious reason and the probe says nothing useful.
GET /api/azure/config, so a setup screen can show a live checklist instead of
an operator inferring configuration from a 500.

Deliberately does NOT construct the onboarding service: that constructor fails
when configuration is incomplete, which is precisely the state this endpoint
exists to describe. It reads the environment directly.

Secret VALUES are never returned -- only whether each one is set. The client id,
redirect uri and endpoints are public by design, since they appear in every
authorize URL, and echoing them lets a setup screen catch the single most common
misconfiguration: a redirect uri that does not match the one registered on the
Entra application, which fails with AADSTS50011 before a password is typed.

What it cannot answer, and says so: whether the Graph application permissions are
declared on the registration or granted in any tenant. Only validate-graph can,
by reading the roles claim of a real app-only token.
GET /api/azure/app/check. Asserts, by machine, every field the runbook
currently asks a human to verify by eye in the portal.

WHY. One of those fields -- undeclared application permissions -- produced a
consent that completed successfully while granting nothing, and read as a code
bug for an hour before the token's roles claim gave it away. Admin consent uses
scope=.default, which grants only what the application DECLARES, so an
application declaring nothing is consented and holds nothing. That is checkable
in one call and now is.

Five assertions:

  signInAudience is AzureADMultipleOrgs   else no customer can ever consent
  AZURE_REDIRECT_URI is registered        else AADSTS50011 before a password
  the four Graph permissions are declared as APPLICATION, compared by role id
                                          because an application object stores
                                          ids and never names -- a check on
                                          names would have nothing to compare
  a credential exists at all
  the soonest credential expiry           warns inside 30 days, errors past it

The expiry check earns its place on its own: this credential is global, so when
it lapses every consented tenant stops working at once, and nothing else in the
product would have said so beforehand. Against the live registration it reports
2027-03-05.

NEEDS NO NEW PERMISSION. Application.Read.All is already required for identity
discovery, and reading one application is within it. Deliberately no write
access: a check that can only report and never repair cannot itself become a way
to repoint the product at a different application.

This is also what makes two setup paths coherent. Whether the registration was
created by hand in the portal or by an automated bootstrap, this is the single
assertion that says the end state is correct -- so the manual and automated
routes converge on one verification rather than each needing its own.

Reading /applications is scoped to our own app by appId filter. The promise this
package keeps is not "never call this endpoint" but "never enumerate a
customer's directory objects with it".
Assigning Reader one subscription at a time is the step that makes Azure
onboarding feel manual, and it goes stale on its own: a subscription created
next month is not covered by an assignment made today.

One assignment at the tenant root management group covers all of them,
including ones that do not exist yet. The reason onboarding could not simply
do that is privilege -- writing there needs User Access Administrator at the
root, which nobody holds by default, because Entra roles and Azure RBAC are
separate systems and administering a directory grants nothing over its
resources. The runbook's answer was a portal toggle, a sign-out, a sign-in,
and a hunt through the IAM blade.

Microsoft exposes the toggle as an ARM call, and this flow already holds the
delegated token it needs. So POST /api/azure/assign-reader takes tenantWide,
and the ordering is the whole safety argument:

  1. Try with the privilege the operator already has. Anyone who has done
     this before is never elevated at all.
  2. On refusal, look for an EXISTING root elevation. One that is already
     there reflects somebody's earlier decision, and removing it afterwards
     would revoke standing access AuthSec never granted -- so report the
     failure rather than touch it.
  3. Otherwise elevate, retry past RBAC propagation, and give it back.

Removal runs on a context of its own, deliberately not derived from the
request: a cancelled browser or an expired deadline must not be the reason a
human is left holding root User Access Administrator, which does not expire.
When removal does fail, the response and the audit row say so and name the
manual fix.

Elevation is reported in full on success too -- attempted, elevated, removed.
An operator should not have to read an audit log to learn that AuthSec briefly
held the widest role in their tenant.

Tests assert the call SEQUENCE, not just the outcome, because a run that
elevated when it should not have looks identical in the result to one that
did the right thing. First tests for this feature; the fake exercises the
WithClient seam that has been unused since it was added.

Also corrects four claims in the flow doc that the code stopped honouring:
Reader is no longer assigned by hand, Graph is no longer called zero times,
GET /applications is called once for our own app object, and
RoleManagement.Read.Directory was missing from the permission table.
Neither showed up in unit tests or in a real end-to-end run against Azure.
Both needed the flow driven against a fake Microsoft that models
authorization and starts from an empty database.

FIRST CONSENT FOR ANY TENANT DIED ON A NOT-NULL CONSTRAINT.
azure_connectors.graph_granted_roles is text[] NOT NULL DEFAULT '{}', but a
nil pq.StringArray is not a zero value GORM omits unless the field declares a
default -- so every INSERT sent an explicit NULL. It survived a real
end-to-end run because that tenant's row already existed and the upsert path
only touches the display columns. Against an empty table, no tenant could be
onboarded at all.

ELEVATION REPORTED removed:true WHILE STILL ASSIGNED.
removeElevation re-derived the assignment id from a principal filter at
cleanup time. When that filter matched nothing the code read it as "already
gone" and set Removed:true -- while root User Access Administrator was still
held. The simulation printed the response saying removed:true next to the
role assignment that was still there.

The id is now captured immediately after elevateAccess returns, while we know
for certain a grant exists, and cleanup deletes that known object. A lookup
miss is no longer success: it says the role may still be assigned and names
the manual fix. Removed:true now means exactly one thing -- a DELETE was
issued and ARM accepted it.

That is the same false-success shape as arm_reader_ok going true on an empty
subscription list. A check that cannot fail is not a check.

Verified end to end in three scenarios. Operator is a Global Administrator:
PUT refused, no existing elevation found, elevate, capture, retry, succeed,
DELETE -- and the tenant is left holding Reader and nothing else. Operator
already elevated: the first PUT succeeds, and no elevate or delete is
attempted at all, so an elevation AuthSec did not create is never touched.
Operator is not a Global Administrator: elevateAccess 403s, the response
carries the az/PowerShell/ARM-template/portal fallback, and nothing is left
behind.
Without prompt=select_account, Microsoft silently reuses whatever account the
browser is already signed in with. When that happens to be a personal account
-- outlook.com, live.com, an Xbox login -- the operator gets "You can't sign in
here with a personal account" and no way to choose a different one, because
nothing ever asked them which account to use.

The refusal itself is correct and not something the app registration should
accommodate. A personal Microsoft account has no Entra directory behind it: no
tenant, no directory objects, nothing for GET /tenants to return. Widening
signInAudience to AzureADandPersonalMicrosoftAccount would let the sign-in
succeed and then make every call afterwards come back empty, which is worse
than a clear error.

So the fix is not to accept more accounts, it is to let the operator pick.

Only on the ordinary sign-in path. prompt=admin_consent already forces its own
account selection and is mutually exclusive with this.
… spot

Onboarding had no first step. AZURE_CLIENT_ID, AZURE_CLIENT_SECRET and
AZURE_REDIRECT_URI were read once at process start and were deployment-global,
so the only way to hand the product an App Registration was to edit .env and
restart. That is not a setup flow, it makes two workspaces on one deployment
share one application, and it rules out a customer bringing their own.

POST /api/azure/config takes the four details from the portal, stores them, and
in the SAME call asks Microsoft whether that application actually has what
onboarding needs. Missing permissions come back named, with the exact portal
path and the warning that they must be APPLICATION permissions rather than
delegated -- the mistake that produces a consent which succeeds and grants
nothing. It answers 200 even when the application is wrong, because "saved, and
here is what to fix" is a different outcome from "rejected" and the operator
needs the list, not an error.

The secret goes to Vault. Postgres gets a path, and AuthRef carries json:"-"
so it cannot leak through a handler that serialises the row wholesale. No
endpoint returns it.

Resolution order matches connector_provider_apps, deliberately: this
workspace's row first, else the deployment env vars. A deployment configuring
neither is unchanged.

Three consequences worth naming, because they are easy to get wrong:

The constructor no longer fails when the environment is empty. It could not:
refusing to construct would make the endpoint that FIXES the missing
configuration unreachable. Ready() answers that question instead, after the
workspace is known.

Every handler is now workspace-bound through serviceFor(). ForWorkspace returns
a COPY -- the service is shared across requests, so mutating it would let one
workspace's credentials serve another's.

The callback binds before it redeems. Microsoft exchanges an authorization code
only for the application that issued it, so a callback authenticating as a
different workspace's application fails with an opaque invalid_client. Which
application that is lives in the state row, hence PeekStateWorkspace -- a read
that grants nothing, with ConsumeState still redeeming exactly once.

Also here: the sign-in authority becomes per-request rather than an env var.
/organizations has to resolve a typed address across every directory AND the
consumer namespace, and when the same address exists in both it can land on the
personal one -- "You can't sign in here with a personal account", with a picker
that keeps offering the same wrong identity. Naming the directory removes the
ambiguity. Per-request because pinning it in the environment would make the
product single-tenant, which is the opposite of the point.

And app/check now accepts AzureADandPersonalMicrosoftAccount as well as
AzureADMultipleOrgs -- both let a customer tenant consent -- reporting the
first as a warning, because Microsoft caps that audience at two client secrets
and refuses it in the national clouds.
Nine call sites reach Microsoft and any of them can be answered 429. The
retry lives in the transport so no call site has to remember it.

Retries 429, 502, 503 and 504. Not 500: the write may have applied, and
re-sending it is worse than reporting the failure. Not 4xx: re-sending a
rejected request does not make it valid.

Client.Timeout stays unset. It would bound the whole request including
retries and cannot vary per call; the caller's context already carries the
bound the caller wants.

Request bodies are re-read through GetBody rather than reused. Reusing the
reader panicked on the first retried POST, caught by a test before it
shipped.
A client secret travels to Microsoft on every token request and has to reach
the deployment through a clipboard, a browser and a request body. A
certificate signs a short-lived assertion instead, and the private key never
moves.

AuthSec generates the key pair. There is no field for submitting one: an
operator pasting a private key sends it down exactly the path this exists to
avoid. If a customer's policy ever requires the key be born in their own HSM,
that case gets its own handling then.

The assertion carries x5t in base64url, as the protocol requires. The
thumbprint reported to an operator is uppercase hex, which is what the portal
lists and what AADSTS700027 quotes. A test asserts the two agree.

Also validates the ARM assignment scope before it is concatenated onto
ARMBase. "https://management.azure.com" + "@10.0.0.7" parses with host
10.0.0.7 and the real host demoted to userinfo, which sent a PUT carrying the
operator's bearer token to any address the deployment could route to.
AssignRole re-checks the parsed host before sending.
prompt=admin_consent is a v1.0 parameter. The v2.0 endpoint rejects it with
AADSTS901001 and the sign-in fails before Microsoft shows anything.
prompt=consent is not a substitute: it consents the scopes in that request,
and the request asks for ARM's delegated scope, not Graph's application
permissions. One /authorize call is one resource.

So: sign in first, then send the administrator to /v2.0/adminconsent for the
tenant the returned token names. The state carries the mode across both legs
so the callback knows which one answered.

The ARM scopes also stop hardcoding management.azure.com. AuthorityBase,
ARMBase and GraphBase are env-configurable for sovereign clouds and
.env.example says to set all three, but the scope constants named the public
cloud anyway -- so a configured Gov or China deployment asked its own
authority for a public-cloud resource.
"azure returned 403 (Authorization_RequestDenied): Insufficient privileges to
complete the operation" is true, useless, and easy to read as "the credential
is wrong" -- which sends whoever is debugging to regenerate a certificate that
was working. The two failures are fixed in different blades of the portal:

  401 invalid_client         wrong credential    Certificates & secrets
  403 Authorization_Request  right credential,   API permissions
                             nothing granted to it

Diagnose names nine of them, each with a title, the fix and whose problem it
is. The permission list comes from RequiredGraphRoleIDs rather than the
message text, so it cannot drift from what the check actually requires.

An unrecognised error returns nil. The raw message always stays; guessing
means telling someone confidently to change the wrong thing.
Admin consent with .default replaces a tenant's existing grants with exactly
what the registration declares. The application never declared ARM's
user_impersonation, so consenting deleted the incremental grant that had been
working, and the next Reader assignment failed with AADSTS65001 on its first
redemption. Verified against the tenant's own oauth2PermissionGrants.

The missing ARM scope is now reported separately from the Graph ones. Merged,
user_impersonation rendered under "Microsoft Graph -> Application
permissions", where it does not exist and cannot be found.

Refresh tokens rotate: Entra returns a new one on every redemption and
supersedes the presented one, so a chain that redeems N+1 times invalidates
its own earlier tokens -- five subscriptions, one granted, four refused. The
auto-setup path now makes one AssignReader call and keeps the rotated token.

Two ways a privilege could be left live:

An ElevateAccess error is not the same as a failed elevation. A timeout, a
reset or a 5xx after the write leaves the operator holding root User Access
Administrator, and this returned before the removal defer existed. It now
looks, gives it back, and says so. ErrNotGlobalAdmin is exempt: it provably
applied nothing.

A workspace that submitted its own application must act as that application
or none. The bind-failure path kept the deployment's clientID, redirectURI
and Microsoft client, and Ready() only mentioned the problem when something
else was also missing -- so with AZURE_* set, a customer's administrator could
be sent to grant admin consent to the wrong application in their own
directory, recorded as though it were right.
Consent, find the tenant, assign Reader across it, verify both planes. The
administrator signs in once and the rest runs behind a status endpoint.

Not a replacement for the step-by-step routes. This path assumes one person
holds both privileges the flow needs -- Global Administrator to consent, Owner
or User Access Administrator to assign Reader -- in the tenant they are
signing in to. When those are different people, or the operator is onboarding
a tenant they do not administer, the separate endpoints are the only thing
that works.

One AssignReader call for the whole tenant, not one per subscription: each
redemption rotates the refresh token and supersedes the last, so a loop
invalidates its own credential partway through.

Tenant-wide grants settle asynchronously, so ARM is validated twice with a
wait between. A single check right after the assignment reports failure on a
grant that is about to work.
Every save demanded a credential, and the only certificate this accepts is one
it generates. So correcting a redirect URI, or re-running the check after
granting consent, minted a new key pair and overwrote the stored one. The
certificate already uploaded stopped matching, and the failure was
AADSTS700027 quoting a thumbprint the operator had never seen. Saving again to
fix it produced a third. Three certificates were generated this way before the
cause was found, and none of it was the operator's doing.

keepCredential saves everything else and leaves the credential alone. A
credential is required only when there is not one already.

The certificate is also handed back on request. Offering it once, in the
moment it was made, meant closing the page had no way back except generating
again -- the same trap from the other side. It is the public half: it verifies
a signature and cannot make one.

Generating no longer runs the Microsoft check. A certificate created a second
ago cannot be on the registration, so the check could only fail with the exact
error the upload is about to fix, reported next to the new key pair.

Also: the thumbprint is matched against Graph's hex as well as the documented
base64 (Graph returns hex, and an uploaded certificate read as missing), and
the expiry reported is the credential in use rather than whichever one the
blob happened to hold.
Every Azure refusal now comes back with a problem object naming the failure,
the fix and whose problem it is, beside the raw message rather than instead of
it.

An unclassified error used to be 400 -- a Postgres outage or an unreachable
Vault reported to the caller as a bad request, sending whoever is on call to
look at the client. Deployment failures answer 500 with fault "authsec": 4xx
means fix the request, 5xx means the deployment is broken and retrying is
correct.

/login and /callback are unauthenticated by necessity, and the configuration
errors name the vault path and carry the secrets-store error verbatim. That
detail makes a broken workspace debuggable from the console and does not
belong in a response anyone on the network can ask for. It goes to the log;
the caller gets the fact.

ConfigStatus no longer discards the error from reading its own row, which made
an unreachable database indistinguishable from a workspace that never
configured anything -- answered with a checklist of environment variables to go
and set.

Two response keys renamed for shape: an error body's "problem" is a Diagnosis
object, so the config endpoint's string is credential_problem. One key holding
two shapes is how a client ends up rendering [object Object].

Adds POST /auto-setup, GET /setup-status and DELETE /config.
A database built from the bootstrap had no azure_app_config table, so a new
deployment could not store its own application and POST /api/azure/config --
the first step of onboarding -- failed on a fresh install while working
everywhere it had been migrated. The incremental migration existed and the
bootstrap had never been updated to match. Verified by running the bootstrap
against an empty database.

Additive: 38 lines, IF NOT EXISTS, no deletions.

.env.example also drifted in three ways. AZURE_SIGNIN_TENANT documented a
default of /organizations where the code reads "common", AZURE_GRAPH_ENDPOINT
was missing from a block that tells sovereign deployments to set all three
endpoints together, and AZURE_ALLOW_ROOT_ELEVATION was undocumented despite
gating a privilege raise.
The block still said the registration is created by hand in the AuthSec home
tenant and marked multi-tenant. That was true before POST /api/azure/config
existed. AuthSec is self-hosted, so one baked-in application would mean every
customer trusting a registration they do not control.

The application is submitted through the API and stored per workspace: the
public fields in azure_app_config, the credential in Vault. The three AZURE_*
variables are a fallback for a single-tenant deployment that would rather bake
one in, and are commented out now because that is what they are. They can also
only carry a client secret, where the API path can generate a certificate
whose private half never leaves the secrets store.
SESSION_SECRET was a variable this feature introduced, that nothing else in
the codebase reads, and that an operator was only told about at the end of an
otherwise complete setup -- ConfigStatus listed it under missing, so a tenant
could be fully onboarded and still stop on a secret nobody had asked for.

The cookie key is now HMAC(JWT_SECRET, "authsec/azure-session-cookie/v1"),
which is independent of the JWT key rather than the same bytes doing two jobs:
a signature valid for one is meaningless to the other, so the cookie key
cannot mint a JWT and the JWT key cannot forge a cookie.

SESSION_SECRET still wins when set and 32+ characters, for deployments that
already configured one and for anyone who wants the two blast radii separate.
Shorter than that it is ignored in favour of the derivation. With neither
there is no key at all, and the caller refuses to issue a cookie rather than
signing one anybody could forge.

Verified with the backend running and SESSION_SECRET unset: ready, nothing
missing.
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