What's new in NOBA.
main: Feature A Automated Defederation, Feature B Post-Migration MFA Enrollment + Temporary Access Pass, and Feature C MFA Coverage Audit Report for NIS2 Art 21(2)(j). All three live-validated against the Nobacmd P2 trial tenant (6/6 live probes on Feature A READ paths, 8/8 end-to-end on Feature B, 5 probes on Feature C); 5142 backend tests pass, 158/158 VitestFederated to Managed via Graph PATCH /domains/{id}. Seven cloud-probed preflight checks (/organization sync state, isRoot / isVerified, federation config backup of 12 fields, signing-cert fingerprint for drift detection), eight-item operator attestation chain with case-insensitive typed domain confirmation, 60-minute propagation window tracked by a background worker with a 90-minute hard cap, and a 24-hour bounded rollback window that restores all 12 captured federation fields on one click. Advisory lock per (tenant_id, domain_id) prevents concurrent flips; pre-PATCH cert-fingerprint drift check guards against stale backups. Sovereign-cloud-aware consent URLs cover global / gcc_high / dod / china. Requires 3 new admin-consented Graph perms: Domain-InternalFederation.ReadWrite.All (primary), Domain.ReadWrite.All (fallback), User.RevokeSessions.All (optional). Destructive flip itself is not live-tested against the trial (P2 tenant is Managed-only — documented residual risk); READ-path and dry-run preflight are live-validatedFEATURE_FLAG_AD_MFA_ENROLLMENT=1). Auto phone + email pre-registration runs as Wave 5 of migration (operator opts-in per project) so migrated users aren't stranded without a strong factor on first cloud-domain login. Operator-triggered Temporary Access Pass issuance with show-once plaintext delivery — the plaintext TAP is never persisted, never logged, never returned on anything but the single one-shot response, enforced by four defense layers (schema has no plaintext column, graph_writer response redactor, engine never passes plaintext to DB, router never returns it twice). Tenant TAP policy preflight clamps the requested lifetime to the tenant policy and passes DisabledByPolicy through as an actionable warning. Full rollback via ad_rollback_driver — 3 new ops (delete_phone, delete_email, delete_tap) with a refusal branch when the user made the registered phone their default MFA. New Graph permission: UserAuthenticationMethod.ReadWrite.All (separate customer tenant re-consent). New DB table ad_mfa_enrollment_log (22 columns, 6 indexes; per-method per-user audit)phishing_resistant / passwordless / mfa / none) per migrated Entra directory. Server-side collector runs on a 24h cadence piggybacked on the sync worker; graph_throttle.priority_scope("Low") keeps a running migration from starving on coverage ticks. License preflight via /subscribedSkus service-plan GUID match (P1 41781fb2-…, P2 eec0eb4f-…) with /organization?$select=assignedPlans as the 403-fallback path; free-tier tenants short-circuit on the Authentication_RequestFromNonPremiumTenantOrB2CTenant 400 response. Classifier is conservative on unknown method strings — never up-classifies to phishing_resistant, always under-reports rather than over-reports. Four export surfaces: streamed per-user CSV + separately signed manifest, aggregation-only CSV, reportlab-rendered PDF (pure Python, no cairo/pango runtime deps), and per-user GDPR erasure. Service principal must also be assigned the built-in Entra Reports Reader role — without it, userRegistrationDetails returns 403 even with AuditLog.Read.All consented. Admin-gated, P1/P2 tenants only. NOBA_MFA_COVERAGE_RETENTION_DAYS env var (default 365) for compliance-evidence retentiongraph_throttle.priority_scope("High"|"Low") context manager attaches the x-ms-throttle-priority request header to every Graph call made inside the scope — no kwarg-threading through 40 signatures. Migration and rollback paths enter High; the background sync worker enters Low; invalid levels silently fall back to Normal. Microsoft throttles Low first and High last, so this is a fairness signal between NOBA's own workloads under cross-tenant throttle pressure, not a limit change. classify_response now extracts x-ms-throttle-information (e.g. CPULimitExceeded, WriteLimitExceeded) for 429 forensics, and parses the IETF-standard RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset headers opportunistically so the pipeline lights up automatically when Graph rolls them out beyond the current SharePoint-preview scopeserver/adr008_entra_watch.py polls the raw MicrosoftDocs/entra-docs markdown every 24h alongside the existing Graph changelog RSS watcher — added after 2026-04-17 investigation of the June 1, 2026 Entra Connect hard-match restriction revealed that change was announced only in the Entra feed, not the Graph changelog. Without the second watcher, an entire class of Entra identity-layer deprecation would not have tripped ADR-008 review trigger #4. Confirmed via grep that NOBA's AD-to-AD path does NOT use onPremisesImmutableId / Entra Connect / Cloud Sync — so the restriction itself does not apply to NOBA migrations, only to customers running Entra Connect in parallel hybrid scenarios (their infra, documented for runbook visibility)AdOperationsTab.vue with internal subnav tabs. The wrapper shipped but was unreachable from the UI and the inner-tabs UX was rejected on verification. Refactored: wrapper deleted, AdDefederationPanel now routes directly at /settings/ad-defederation (admin-only), AdMfaEnrollmentPanel at /settings/ad-mfa-enrollment (operator-or-admin), both surfaced as top-level sidebar entries under PROGRAMS → AD OperationstoLocaleString; active-run header pulls the full run_id after the store normalizes it across the id / run_id API drift; flipped-state propagation countdown ticks every second ("Propagation in progress — HH:MM:SS remaining"); defederation + sync log tables match each other with uppercase headers and border-collapse; Connect button no longer wraps mid-word; Health Score gauge empty state breathes with an accent-tinted pulse instead of reading as a dead disc; Live-pill heartbeat blink restored in the app header; API Docs sidebar link hidden unless apiDocsEnabled is on in YAML (was dead-linking to 404 by default)otel_ad_defederation.py had been hard-importing from opentelemetry import metrics since the first Feature A commit, crashing 5012 pytest tests plus 7 test-live collection errors in CI environments where opentelemetry-api isn't installed (NOBA's CI pip install lines are curated allow-lists, not requirements.txt). Applied the same try/except Noop fallback pattern otel_graph.py already uses; counter/histogram calls degrade to no-ops. All 14 gitea Actions jobs green on the merge commitdompurify bumped ^3.3.3 → ^3.4.0 to close GHSA-39q2-94rc-95cp (moderate; ADD_TAGS / FORBID_TAGS bypass). NOBA's only use at AiChatPanel.vue:56 is the ALLOWED_TAGS whitelist path and was not vulnerable, but the bump brings npm audit --omit=dev back to 0 vulnerabilitiesrelease-gitea.yml now authenticates to Docker Hub before the docker build base-image pull (gated on two gitea secrets: DOCKERHUB_USERNAME + DOCKERHUB_TOKEN, using a dedicated read-public-repos service account). The unauthenticated anonymous pull had started hitting Docker Hub's 100/6h rate limit mid-release, 429-failing the build on valid tags. Authentication alone lifts the ceiling to 5000/24h/account regardless of PAT scopeghcr.io to a SHA-verified tarball served directly from R2 under nobacmd.com. Previous operators who followed docker pull ghcr.io/raizenica/noba-enterprise:latest were silently pulling an older version or hitting a 401 ever since the upstream code repo went private in early April — the new flow fixes that with zero registry dependencycurl -fL https://www.nobacmd.com/download/latest/docker-install.sh | bash resolves the current version via /download/latest/version.json, downloads noba-enterprise-<V>.docker.tar.gz and its .sha256, verifies the hash, gunzips, docker loads, and tags the image as noba-enterprise:latest so docker run / docker compose up work immediately. Pin a specific version with NOBA_VERSION=… — the script reads the env var before hitting the version.json. Checks prerequisites (docker daemon reachable, curl, gunzip, sha256sum) and fails fast with operator-friendly messages rather than half-loading an imagerelease-gitea.yml runs docker build + docker save | gzip at every tag push, uploads the .docker.tar.gz + SHA to the Gitea release and to s3://noba-releases/v<V>/. docker-install.sh is uploaded to both the per-version path and the stable /latest/ path so the website one-liner resolves without any intermediate redirect. version.json gains a "docker" field so the install script picks up the tarball path without extra round-tripsdocker-compose.yml now uses image: noba-enterprise:latest (the local tag the install script writes). Header comment documents the 3-step operator flow — curl-bash, compose up, read the first-run password from docker logs. build-from-source escape hatch preserved one line below/download/latest/docker-install.sh endpointUser-Agent: noba-command-center/2.1 on /v1/watchers/login, which some LAPI deployments reject with HTTP 401 because the stored machine User-Agent (set at cscli machines add time) doesn't match. The watcher JWT login and every authenticated call now use crowdsec-lapi-client-noba/2.1, matching CrowdSec's upstream bouncer / LAPI-client naming convention so LAPI recognizes NOBA as a legitimate watcher. Validated end-to-end against Docker crowdsecurity/crowdsec:v1.7.7 — the A/B UA probe returns 401 on the old UA and 200 + token on the new onenoba-web.service now carries StartLimitBurst=5 and StartLimitIntervalSec=60 in the [Unit] section. Prevents the scenario where an orphaned LISTEN socket survives a crash and wedges port 8080 across every subsequent restart — after five failed starts in sixty seconds systemd stops retrying and the failure becomes visible instead of silently looping. Complements the SO_REUSEADDR patch that shipped 2026-04-02. Validated via a user-scope systemd-run crash-loop harness — journal reports "Start request repeated too quickly" at exactly restart counter is at 5, matching the configured burst/api/agent/install-script?type=rust&platform=windows, no more references to building from source or the deprecated Python-agent install script. The release workflow cross-compiles noba-agent-<version>-windows-x86_64.exe plus a SHA-256 checksum at tag time and publishes both alongside the tarball and DEB — the Windows binary used to live only in a 30-day CI artifact, now it rides the tag. Validated by this tag push: the .exe is live on the Gitea release page and on the R2 download bucket at /download/v2.1.0-beta.33/server/adr008_cve_watch.py background thread polls OSV every 24h for new advisories on eight packages whose CVE feeds would indicate NOBA's hand-rolled OAuth2 client-credentials pattern has the same bug shape: azure-identity, msal, msgraph-sdk (the Microsoft SDKs intentionally not adopted), authlib, oauthlib, requests-oauthlib (functionally equivalent Python OAuth2 clients), and PyJWT + httpx (direct deps). Advisories with CVSS ≥ 7.0 fire a WARNING-level log line plus an audit-trail event under category adr008_cve_watch, surfacing the ADR-008 review requirement the moment it becomes relevant. Seen CVE IDs persist to ~/.local/share/noba-adr008-cve-seen.json to prevent re-alerting on every pollserver/adr008_graph_watch.py background thread polls the Microsoft Graph changelog RSS feed every 24h for deprecation, deprecated, v2.0, version 2.0, breaking change, retirement, retired, and sunset keywords. Matches fire a WARNING log + audit-trail event under category adr008_graph_watch so NOBA sees the 24-month v2.0 migration window the moment Microsoft announces it — well before the SDK-adoption vs. raw-HTTP cost trade-off becomes time-critical. Closes the "scheduled task on the Graph changelog" open question raised when ADR-008 was filedpytest-xdist -n auto. Lifespan smoke on fresh HOME clean — both watchers start with the app lifespan hook and stop cleanly during shutdowndocs/adr/ADR-008-hand-rolled-graph-client.md updated with pointers to the new modules, so a future auditor reading the ADR knows the triggers are enforced in code, not just documentedauth_social.py audit after beta.29 (P0) and beta.30 (P1) shipped. The full audit scope is now implemented; NOBA's Entra SSO matches the OpenID Connect 1.0 spec end-to-end for authorize, callback, token validation, claims-challenge, logout, and single-logoutGET /api/auth/social/microsoft/logout?token=<noba-token> revokes the current NOBA session immediately and 302s the browser to Entra's end_session_endpoint with a post_logout_redirect_uri back to the NOBA login page. Works regardless of whether the IdP actually supports RP-initiated logout — if end_session_endpoint is absent from the discovery doc, the handler clears the local session anyway and returns the user to the login pagePOST /api/auth/social/microsoft/backchannel-logout receives form-urlencoded logout_token JWTs directly from Entra per the spec. Validation enforces every required check — signature against the tenant JWKS, iss, aud, iat, required jti freshness, required events claim containing http://schemas.openid.net/event/backchannel-logout, absent nonce (spec §2.6 — logout tokens MUST NOT carry a nonce), at least one of sub or sid. On success, NOBA sessions matching the email claim are revoked via the existing revoke_user_sessions helper. Sub-to-session mapping (so the IdP can terminate sessions it knows about but we don't have an email for) is scheduled for a later releasejti replay cache. auth_oidc_verify.verify_id_token now rejects any id_token whose jti claim has been seen before. Cache is a bounded in-memory OrderedDict (10k entries) with time-based eviction on every lookup. Catches the case where an id_token is captured in transit and replayed before it naturally expires. The same cache backs the freshness check required by verify_logout_token so a single logout event cannot be replayed to terminate additional sessions/me GET in auth_social.py were previously synchronous httpx.post/get calls inside async def handlers — each one blocked the event loop for the duration of the Entra round-trip (200–1500ms under normal load, longer on slow networks). beta.31 converts them to httpx.AsyncClient and awaits them properly. No functional change; removes head-of-line blocking during login under concurrent loadauth_oidc_verify.verify_logout_token exposes the back-channel-logout validation as a public surface so future integrations (Keycloak, Authentik, Google workspace) can adopt it without re-implementing the spec. 8 new tests; 4810 total pass under pytest-xdist -n auto. Lifespan smoke on fresh HOME cleanauth_social.py audit after beta.29 closed the five P0 NIS2 gaps. beta.30 closes the P1 tier — strict at_hash, Conditional Access insufficient_claims surface-through on the login path, and generic-OIDC discovery hardening per RFC 8414 §3.3. P3 items (logout semantics, jti replay cache, async-httpx conversion) remain scheduled for beta.31at_hash when an access_token is issued. auth_oidc_verify.verify_id_token now takes a require_at_hash parameter; the Microsoft callback sets it to True on every response that carries an access_token, so a missing at_hash claim is a hard failure per OIDC Core §3.1.3.6. Previously the check was silently skipped when the claim was absent — spec-compliant only when no access_token was returned_exchange_code and _fetch_userinfo now detect the RFC 6750 / RFC 9470 challenge (WWW-Authenticate: Bearer …, error="insufficient_claims", claims="…"), raise ConditionalAccessError, and the callback redirects the browser to a fresh /authorize with the claims parameter attached — so the user can satisfy the CA policy (MFA, device compliance, managed identity attestation) and resume the original flow. The same challenge was already handled by the Graph connector for workload-identity calls; beta.30 extends coverage to interactive login. Mirrors on the /link callback too_resolve_provider now refuses to disable TLS verification (oidcVerifySsl=false) for non-localhost URLs unless the operator explicitly sets oidcAllowInsecureDev=true — the development escape hatch is gated to loopback and .local / .lan / .test / .localhost / .internal TLDs. After a successful OIDC Discovery fetch, the issuer field in the document must match the URL used to fetch it (RFC 8414 §3.3) — mismatches are now rejected instead of silently accepting whatever authorize and token endpoints the document advertisedpytest-xdist -n auto. Lifespan smoke on fresh HOME cleanauth_social.py audit. The prior flow reduced OIDC to "trust whatever email the userinfo endpoint returns over TLS" — a stolen access token, a compromised client secret, or a mis-scoped /common/ tenant all bypassed authentication. All five gaps map to ASVS 5.0 V6.1–V6.2 requirements an external NIS2 auditor checks/login and /link redirect; the verifier is bound to the session via the existing OAuth state map and sent on the callback token exchange. A 32-byte nonce is issued alongside every authorize-request and validated on the returned id_token with a constant-time comparisonserver/auth_oidc_verify.py module validates Entra id_tokens against the tenant's JWKS — RS256 signature, expected issuer, expected audience, exp/iat/nbf with 300s leeway, required-claim check, nonce binding, optional at_hash access-token binding, and optional tid allowlist. JWKS are cached 24h with automatic kid-miss refresh per Microsoft Learn guidance. Hand-rolled per ADR-008, no MSAL adoptiontenantId; the /common/, organizations, and consumers endpoints are rejected unless an operator opts into allowMultiTenant. An optional allowedTids allowlist is enforced against the id_token's tid claim — closes the "any Entra user anywhere can log in" oracle called out by the auditsocialProviders.<provider>.allowJitProvision (default false) across every provider. Combined with the Microsoft tid allowlist, this closes the account-creation oracle at auth_social.py:251 that the audit flagged as combining with /common/ into a trivial privilege-escalation vectorPyJWT>=2.12.0 added to all six pip install surfaces. Floor was chosen from PyPI latest + OSV advisories + NVD CPE cross-check: closes CVE-2026-32597 (CVSS 7.5 HIGH, crit header bypass, fixed in 2.12.0) and CVE-2024-53861 (CVSS 7.5 HIGH, iss partial-match, fixed in 2.10.1). The initial audit memo proposed >=2.9.0; verification caught that the proposed floor would have left both CVEs open/common/ toggle — matches the new backend config shapeat_hash enforcement where spec-required, Conditional Access insufficient_claims surface-through on the login path, generic-OIDC discovery issuer-match hardening) scheduled for beta.30 per the audit memo's tag-split plan; P3 items (front-/back-channel logout semantics, id_token jti replay cache, async-httpx conversion) for beta.31msgraph-sdk's Kiota middleware provides natively — the "keep hand-rolled" decision documented in ADR-008 now has feature parity on the observability side, closing the NIS2 audit-trail gap it createdopentelemetry-exporter-otlp pipeline light up the new instruments immediately.github/workflows/release.yml gated to GitHub Actions only — the 7 package-build jobs (version, agent-binaries, tarball, rpm, deb, arch, release) now skip cleanly on Gitea's self-hosted Act-runner instead of failing and burning CPU cycles that could take ~430 minutes per tag push on a 24-core hostNOBA_REDIS_URL is unset or unreachable — multi-worker deployments silently lose cross-worker throttle coordination without a shared cache, and in-memory fallback multiplies the 429 rate by the worker count under real migration loadredis:8-alpine with AOF persistence, a healthcheck, a named volume, and NOBA_REDIS_URL pre-wired — zero operator action for the default containerized pathredis>=7.4 declared across all six pip install surfaces (source dist, Docker image, installer script, three CI workflow venv setups). Verified clean against OSV (historical redis-py CVEs are all on the 4.x branch, fixed by 4.5.4) and NVD for the upstream Redis server image (8.6.2 is patched for CVE-2025-49844 Lua UAF CVSS 9.9 plus five more high-severity CVEs)docs/enterprise-setup.md gained a Redis requirement section with per-major-branch minimum-safe server versions for operators running their own cache; the three-way version-sync invariant (pyproject.toml + config.py + CHANGELOG.md) is now validated by the release workflow's extract-and-validate jobpython-multipart>=0.0.22 floor raised to close three CVEs that were live at the prior floor — CVE-2024-24762 (ReDoS in Content-Type header parsing affecting every FastAPI upload endpoint), CVE-2024-53981 (DoS via excessive boundary allocation), and CVE-2026-24486 / GHSA-wp53-j4wj-2cfg (arbitrary file write via boundary-crafted non-default filename handling). Applied across every pip install surface so fresh resolution cannot silently pick a vulnerable transitive versionPyMySQL>=1.1.1 floor raised to close CVE-2024-36039 (SQL injection via improper dict-key handling when binding JSON_TYPE parameters). Applied in the pyproject mysql extras, requirements-enterprise.txt, and the CI workflow's mysql-marked tests venvmsal / azure-identity / msgraph-sdk. Companion evidence pack at docs/audit/ contains verbatim quotes from Microsoft Learn, ENISA NIS2 Technical Implementation Guidance v1.0, OWASP ASVS 5.0 V6, OWASP A06:2021, NIST SP 800-204B, and CVE-2024-35255graph_throttle.py is strictly more capable than msgraph-sdk's Kiota middleware for NOBA's workload (pre-429 self-pace via x-ms-throttle-limit-percentage, token-bucket accounting against three published Graph rate limits, and cross-worker Redis coordination the SDK does not implement)Retry-After and x-ms-throttle-limit-percentage header classification, and a post-create replica-race wrapper now covers Azure groups, users, and Administrative Units (previously AU-only)confirm() dialog — accessible, theme-aware, and reachable from automation harnessesshare/noba-web/requirements.txt as the single source of truth for Python dependencies — the previous hand-maintained dep list had drifted out of sync and silently broke container startup when imports landed without the matching dep linenoba-agent.exe, registers it as a Windows service, and writes its config — no more "build from source" placeholdernoba-agent: 2.8 MB musl static binary across five crates, with self-update verified via SHA-256 + Ed25519, a protobuf agent protocol (JSON fallback for legacy agents), and cross-platform capture across X11 SHM, Wayland wlr-screencopy, kernel fbdev, and Windows DXGI + GDIX-Requested-By CSRF header and per-call HTTP clients for self-signed instancesexecute_op(), bringing the integration surface to zero placeholder modulesUNVERIFIED and unsupported boundaries where evidence still is not therecryptography to >= 46.0.7 to close the upstream GHSA exposure on Python > 3.11 and documented the reason inline in pyproject.tomllive, ad, and saml pytest markers so live-infra coverage no longer depends on loose keyword matchingrun_id, so operators can trace and cancel executions instead of receiving a null handle