Public beta scope: AD workflows have real-system path validation, but NOBA is still under active verification. Treat compliance evidence and self-healing claims as evaluation surfaces unless the source and configured state are shown. Read validation boundaries.
← All posts

Three AD Operations features, one release — cloud-flip, MFA provisioning, and NIS2 coverage audit ship together

2026-04-18

Migrating from one Active Directory forest to another — or from on-prem AD to Entra ID — is three promises operators make to their security team. The first is "I can undo this." The second is "people can actually authenticate on the target, not just exist there." The third is "we can show an auditor what our MFA posture looks like after the dust settles." beta.35 ships all three as one coherent AD Operations surface, because they're load-bearing for each other — a migration without rollback is irresponsible, a migration without post-migration authentication is incomplete, and a migration without an auditable MFA coverage report doesn't pass NIS2.

Feature A — Automated Defederation with a 24-hour rollback window

For years, flipping a domain from Federated to Managed in Entra ID has been a runbook item that starts with "warn your help desk" and ends with "pray." NOBA now automates the flip itself (PATCH /domains/{id}) and wraps it in a safety envelope designed to absorb the cases where something goes sideways.

Seven cloud-probed preflight checks run before anything is touched: /organization sync state, isRoot and isVerified on the domain object, the full /domains/{id}/federationConfiguration document captured as a 12-field restorable backup, the signing-certificate fingerprint captured for drift detection, and the reverse-op metadata written to the audit log before the PATCH is dispatched. An eight-item operator attestation chain asks the things NOBA can't cloud-probe — whether Pass-Through Authentication is running, whether a third-party IdP is involved, whether enforceMfaByFederatedIdp is on — plus a case-insensitive typed confirmation of the domain name that can't be click-bypassed.

The flip is admin-gated. Concurrent flips on the same (tenant_id, domain_id) are blocked by an advisory lock mirroring the pattern the AD sync worker already uses. Between the preflight capture and the PATCH, the signing-cert fingerprint is re-read and compared — if it's drifted, the run aborts before touching the cloud, because a backup captured against a rotated cert can no longer be restored.

After the PATCH lands, a PropagationPollWorker running on a 2-minute tick watches the domain's authenticationType flip to Managed — the typical window is 60 minutes, the hard cap is 90. A separate FinalizerWorker tracks the 24-hour rollback window that stays open after propagation confirms. One click during that window restores all 12 captured federation fields and PATCHes the domain back to Federated. If a stale federation-configuration object was left on the domain by the flip, it's deleted before the restore POST so the operation is idempotent. When 24 hours elapse, the run transitions to finalized — the rollback capability is released and audit-logged.

The destructive flip itself is not live-tested. The Nobacmd P2 trial tenant is Managed-only and there's no way to set up a real Federated domain inside a trial — that's a documented, accepted residual risk named in the spec. The READ paths and an FP-1-fails-cleanly dry-run are live-validated against the real tenant on every release cut.

Three new Graph permissions are required (admin consent once per tenant): Domain-InternalFederation.ReadWrite.All as primary, Domain.ReadWrite.All as fallback, and User.RevokeSessions.All optional/default-off for the session-invalidation path. OnPremDirectorySynchronization.Read.All was in an earlier draft of the bundle and was removed during reviewer pass — it's delegated-only and inappropriate here.

The release also ships sovereign-cloud-aware consent URLs covering global / gcc_high / dod / china, surfaced on 403 failure paths so an operator running in a sovereign cloud gets a working link, not a broken one.

Feature B — Phone + email pre-registration, plus Temporary Access Pass issuance

The honest gap in the pre-beta.35 migration flow was authentication-on-target. Users were created on Entra, but they had no MFA factors registered — which meant the first login required a password reset, an operator phone call, and a self-service setup that assumed the user was at a desk with their own phone. That's fine for pilot cohorts and a dealbreaker for scaled migrations.

beta.35 ships Feature B behind a flag (FEATURE_FLAG_AD_MFA_ENROLLMENT=1). When enabled, phone and email pre-registration run as Wave 5 of the migration — operator opts-in per project, so nothing changes on existing deployments without explicit consent. Migrated users arrive with a phone and an email already bound as authentication methods, ready to satisfy a Conditional Access policy on first sign-in.

The other half is operator-triggered Temporary Access Pass issuance. An operator can mint a one-time-use TAP for a migrated user; the plaintext is returned exactly once, on the issuing response. It is never persisted, never logged, and cannot be re-fetched after the response closes. That invariant is enforced by four defense layers: the ad_mfa_enrollment_log schema has no column for plaintext, the graph_writer response redactor strips it, the engine never passes it to the DB, and the router only returns it on the single one-shot surface. A post-issuance GET on the same TAP ID returns a record of what was issued but not the plaintext itself. If an operator loses the plaintext, the only remedy is revoke + reissue.

Before the TAP mint dispatches, a tenant TAP-policy preflight runs. Tenants set minimum and maximum lifetimes, and mint requests outside the policy either get silently clamped (when the request exceeds the max, we clamp down to the max and surface it as a warning) or the DisabledByPolicy error is passed through as an actionable message — not swallowed into a generic 500.

Everything Feature B writes is reversible through the existing ad_rollback_driver. Three new reverse ops land with the feature: delete_phone, delete_email, delete_tap. One refusal branch is intentional: if a user made the registered phone their default MFA method after migration, the rollback refuses to delete it unprompted — the operator has to either move the default first or explicitly override, because silently deleting a user's default MFA is the kind of ticket you never want to open.

One new Graph permission: UserAuthenticationMethod.ReadWrite.All. This is a separate customer-tenant re-consent from Feature A's bundle — operators enabling both features consent once for each.

Live validation: 8 end-to-end probes against the Nobacmd P2 trial tenant covering the flows that had historically blown up on us — LP-1/2/3 (the paging, throttle, and consent shapes) plus LP-8 and LP-9a/b/c (the TAP lifetime and refusal edge cases).

Feature C — MFA Coverage Audit Report (NIS2 Art 21(2)(j))

The third leg is read-only: a compliance-grade report, per migrated Entra directory, that surfaces the 4-bucket authentication posture an NIS2 Art 21(2)(j) audit asks about. The buckets are phishing_resistant (FIDO2 / passkey / WHfB), passwordless, mfa, and none. A server-side collector (ad_mfa_coverage_collector.py) runs on a 24-hour cadence piggybacked onto the existing sync worker, with an in-memory _try_acquire concurrency guard mirroring how ad_sync_worker._tick already protects itself. The collector enters graph_throttle.priority_scope("Low"), so a running migration — which enters High — always wins contention if the tenant hits a Graph throttle ceiling. Coverage is observability; it doesn't starve the real work.

License preflight runs via /subscribedSkus and matches service-plan GUIDs directly (41781fb2-… for P1, eec0eb4f-… for P2); /organization?$select=assignedPlans is the 403-fallback for tenants whose service principal doesn't have LicenseAssignment.Read.All. Free-tier tenants short-circuit on the Authentication_RequestFromNonPremiumTenantOrB2CTenant HTTP 400 response code — NOBA surfaces a clear banner instead of looping retries against an endpoint that'll never return data.

The classifier is intentionally conservative. Unknown methodsRegistered strings — which do appear when Microsoft lights up new authentication methods — are safe-defaulted. They never up-classify to phishing_resistant. If we don't know what a method is, we under-report, not over-report. An adr008_auth_methods_watch.py drift watcher polls the raw Microsoft Graph authenticationmethodmodes.md and concept-authentication-strengths.md sources every 24 hours and fires a WARNING + audit-trail event when the vocabulary changes, so operators have a signal to update the classifier mapping before an unknown string shows up in production data.

The service-account heuristic is operator-configurable with three OR'd rules (UPN regex, employeeType exact match, stale-signin N-day threshold). Guests and disabled users are enumerated via a separate /users?$filter=accountEnabled eq false Graph call, excluded from the coverage percentage, and visibly reported — so auditors see the denominator, not just the numerator.

Four export surfaces: streamed per-user CSV with a separately signed manifest, aggregation-only CSV, reportlab-rendered PDF (pure Python, no cairo/pango runtime deps — important for container hygiene), and per-user GDPR erasure at POST /api/enterprise/ad/mfa-coverage/erase-user. Export integrity rides on sha256 in the existing db.audit_log HMAC chain — no pyhanko signing, because we're not trying to compete with certified e-signature stacks, we're trying to give auditors evidence that the export wasn't tampered with between the collector and the auditor's desk.

A new NOBA_MFA_COVERAGE_RETENTION_DAYS environment variable (default 365) controls how long raw coverage rows stay in the DB. Compliance-evidence retention is deployment-specific — default is a year, operators tune from there.

Feature C is admin-gated and P1/P2-tenants-only. It requires five Graph permissions consented once per tenant: AuditLog.Read.All, LicenseAssignment.Read.All, Domain.Read.All, Policy.Read.AuthenticationMethod (watcher only), UserAuthenticationMethod.Read.All (drill only). One operational gotcha that cost us a live test: the service principal also has to be assigned the built-in Entra Reports Reader role. Without it, userRegistrationDetails returns 403 even with AuditLog.Read.All consented. That's documented in the runbook and surfaced as a clear error on the first collector tick instead of silently failing.

Why all three in one release

Individually, each of these features would be a defensible release. Bundled, they close a loop that operators evaluating NOBA for an AD consolidation have been asking about since the AD migration runner first shipped: can I undo a tenant-scope change, can I get users authenticating on the target, and can I prove MFA posture to an auditor. The answer up to beta.34 was partial on all three. beta.35 makes each one an explicit end-to-end answer — with the specific non-coverage gaps named in the changelog rather than hidden.

How we validated it

The pre-release state of the testing floor:

Note on Feature A's destructive path: it is not live-tested against the trial tenant, because P2 trials are Managed-only. The spec documents this as accepted residual risk; the READ paths and an FP-1-fails-cleanly dry-run carry the live-validation weight, and the destructive PATCH is covered by unit + integration tests with real API contract fixtures captured from Graph.

Two side-orders worth flagging

A second ADR-008 watcher for the Entra whats-new feed. ADR-008 ("keep the hand-rolled Graph client") has a written review-trigger #4: "surface any Microsoft-announced deprecation / breaking change within one polling interval." Until beta.35 we ran one watcher, against the Graph changelog RSS. A 2026-04-17 investigation of the June 1, 2026 Entra Connect hard-match restriction — a real behavioral change Microsoft is rolling out — revealed that that change was announced only on the Entra whats-new feed, not the Graph changelog. The single-feed watcher would have been silent on it. beta.35 adds adr008_entra_watch.py, a second polling worker on the raw MicrosoftDocs/entra-docs markdown. Same 24h cadence, same keyword list, same WARNING + audit-trail event shape. If Microsoft announces something on either feed, we see it.

Separately: a grep audit confirmed NOBA's AD-to-AD path does not use onPremisesImmutableId or Entra Connect or Cloud Sync — we create net-new cloud users via raw POST /users, not via SoA takeover of existing cloud-managed role-holding users. The June 2026 restriction therefore does not apply to NOBA migrations. Customers running Entra Connect alongside NOBA in hybrid scenarios may still hit it on their Entra Connect agent — their infra, worth a runbook note, but no NOBA code change needed.

Graph throttle priority as an internal fairness signal. We added graph_throttle.priority_scope("High"|"Low"), a context manager that attaches the x-ms-throttle-priority header to every Graph call made inside the scope. Migration and rollback paths enter High (user-initiated, should win contention). The background sync worker enters Low (daemon, should yield first). Invalid levels silently fall back to Normal so a typo can't break a migration run. Microsoft throttles Low first and High last, so this is a fairness signal between NOBA's own workloads under cross-tenant throttle pressure — limits themselves are unchanged. Alongside, classify_response now extracts x-ms-throttle-information (CPULimitExceeded, WriteLimitExceeded, ResourceUnitLimitExceeded) for 429 forensics, and opportunistically parses the IETF-standard RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset headers so the pipeline lights up automatically when Graph starts returning them beyond the current SharePoint-preview scope.

Housekeeping — CI, dompurify, Docker Hub

Three things worth naming in a release note even though none of them change product behavior:

Upgrade path

Existing operators on beta.34 can pull beta.35 through the standard flow:

curl -fL https://www.nobacmd.com/download/latest/docker-install.sh | bash
docker compose up -d

Or pin explicitly:

curl -fL https://www.nobacmd.com/download/latest/docker-install.sh | NOBA_VERSION=2.1.0-beta.35 bash

The self-update check at /api/system/update/check will surface beta.35 on its next tick for running instances. Features A, B, and C all require fresh Graph-permission consent per tenant — Feature A's 3-permission bundle, Feature B's one new permission, Feature C's five. Feature B additionally needs the FEATURE_FLAG_AD_MFA_ENROLLMENT=1 environment variable to opt-in; Features A and C are gated by the existing ad_cross_domain entitlement on the license. The new sidebar entries under PROGRAMS → AD Operations surface after consent lands; admins see all six items, operators see only MFA Enrollment, and non-operators don't see the group at all.

Try the flows on the interactive demo before you wire real credentials — the demo mirrors the preflight, attestation, and rollback-window UX against mock data, so you can walk the full operator path end-to-end and see exactly what hits the screen before you consent new Graph permissions on a live tenant.

Comments

No comments yet. Be the first.

Comment posted.