TenantAtlas/specs/063-entra-signin/spec.md
ahmido c5fbcaa692 063-entra-signin (#76)
Key changes

Adds Entra OIDC redirect + callback endpoints under /auth/entra/* (token exchange only there).
Upserts tenant users keyed by (entra_tenant_id = tid, entra_object_id = oid); regenerates session; never stores tokens.
Blocks disabled / soft-deleted users with a generic error and safe logging.
Membership-based post-login routing:
0 memberships → /admin/no-access
1 membership → tenant dashboard (via Filament URL helpers)
>1 memberships → /admin/choose-tenant
Adds Filament pages:
/admin/choose-tenant (tenant selection + redirect)
/admin/no-access (tenantless-safe)
Both use simple layout to avoid tenant-required UI.
Guards / tests

Adds DbOnlyPagesDoNotMakeHttpRequestsTest to enforce DB-only render/hydration for:
/admin/login, /admin/no-access, /admin/choose-tenant
with Http::preventStrayRequests()
Adds session separation smoke coverage to ensure tenant session doesn’t access system and vice versa.
Runs: vendor/bin/sail artisan test --compact tests/Feature/Auth

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #76
2026-01-27 16:38:53 +00:00

13 KiB
Raw Blame History

Feature Specification: 063 — Entra Sign-in (Tenant Panel) v1

Feature Branch: 063-entra-signin Created: 2026-01-26 Status: Draft (v1) Scope: Tenant Admin panel (/admin) sign-in only

Context / Goal

TenantAtlas needs a clean, enterprise-grade sign-in flow for the Tenant Admin panel (/admin) using Microsoft Entra ID (OIDC).

This feature MUST:

  • provide a Microsoft Entra sign-in entrypoint on /admin/login (Entra-only enforcement is finalized in Feature 064)
  • upsert tenant users safely keyed by (entra_tenant_id, entra_object_id)
  • route users after login based on tenant memberships
  • keep pages DB-only at render time
  • log safely (no tokens/claims dumps)

This feature intentionally does not change platform/operator access (/system) or break-glass behavior; those belong to Feature 064-auth-structure.

Scope boundary (063 vs 064)

063 introduces Entra OIDC sign-in capability for the tenant panel:

  • Entra redirect + callback
  • Safe user upsert keyed by (tid, oid)
  • Membership-based post-login routing (0 / 1 / N memberships)

Making "/admin" fully Entra-only (removing local form login and removing any break-glass/operator UX from /admin) is finalized in Feature 064 (Auth Structure).

Clarifications

Session 2026-01-26

  • Q: For a user belonging to multiple tenants, what should happen immediately after they sign in? → A: Redirect to a dedicated, full-screen "tenant chooser" page that lists all their memberships. The user must click one to proceed to the main dashboard.
  • Q: If a user record exists in a disabled state (e.g., deleted_at is not null, or an is_active flag is false), and that user completes a valid Entra ID sign-in, what should happen? → A: Block the login. Redirect the user to the login page with a generic error message (e.g., "Your account is disabled. Please contact an administrator.").
  • Q: What is the v1 DB strategy for users.entra_tenant_id / users.entra_object_id? → A: Keep existing columns/types if present (currently varchar(255) nullable) and keep the existing unique composite index. Do not enforce NOT NULL or type-change in v1.

Session 2026-01-27

  • Q: How should the Entra Tenant ID (tid) be treated in logs? → A: Log the entra_tenant_id in plaintext. Rationale: tid is a tenant identifier, not a user secret. Plaintext is valuable for operations and incident response. The primary PII (oid) is hashed.

Existing System Compatibility (important)

The repository already contains:

  • A System Panel at /system using guard platform and platform_users (platform operator access).
  • Break-glass recovery mechanics for platform operators (banner/middleware/routes).
  • Tenant membership storage using a pivot table (tenant_user or equivalent) with role values via TenantRole enum.

Compatibility constraints for 063

  • 063 MUST NOT modify /system panel, platform guards, platform users, or break-glass routes/UX.
  • 063 MUST NOT refactor tenant RBAC data model or enforcement. It may only read memberships to decide where to redirect after login.
  • 063 MUST NOT introduce Graph calls (or any outbound HTTP) during render/poll/hydration of /admin pages.

Non-Goals (explicit)

  • No Platform Operator /system login implementation (already exists or handled elsewhere).
  • No break-glass UX on /admin/login.
  • No tenant RBAC redesign or role enforcement changes (assume memberships already exist).
  • No Graph calls or remote work in login render/poll/hydration.
  • No storing of Entra access/refresh tokens.
  • No queue/worker dependence for login (login is synchronous request/response).

User Scenarios & Testing (mandatory)

User Story 1 — Entra sign-in entrypoint on /admin/login (Priority: P1)

A tenant user can start Microsoft Entra sign-in from /admin/login.

Acceptance Scenarios

  1. Given I open /admin/login, then I see only “Sign in with Microsoft” and no email/password fields.
  2. Given Entra config is missing/invalid, /admin/login still renders and shows a generic message (no secrets).

Independent Test

  • Render /admin/login and assert no password inputs exist.

User Story 2 — OIDC callback upserts tenant identity safely (Priority: P1)

The callback upserts a tenant user using Entra claims.

Acceptance Scenarios

  1. Given Entra callback includes tid and oid, when sign-in completes, then users is upserted keyed by:
    • entra_tenant_id = tid
    • entra_object_id = oid
  2. Given sign-in succeeds, then the session is regenerated.
  3. Given callback is missing tid or oid, then redirect to /admin/login with a generic error.
  4. Given a user exists but is disabled (soft-deleted), when they complete a valid sign-in, then they are redirected to /admin/login with a generic error.

Independent Test

  • Fake a callback payload and assert (tid, oid) uniqueness is enforced.

  • Q: What is the specific title and primary message for the /admin/no-access page? → A: Title: "No Access", Message: "Please contact an administrator for access."

User Story 3 — Post-login routing is membership-based (Priority: P1)

After login, routing depends on Suite tenant memberships.

Acceptance Scenarios

  1. Given I have 0 memberships, then redirect to /admin/no-access.
  2. Given I have exactly 1 membership, then redirect into that tenants dashboard.
  3. Given I have >1 memberships, then redirect to a dedicated, full-screen tenant chooser page, displaying tenant name and role for each option.

Independent Test

  • Seed memberships and assert each redirect path.

User Story 4 — Filament-native “No access” page (Priority: P2)

Users without memberships get a safe, Filament-native page.

Acceptance Scenarios

  1. Given I have 0 memberships, /admin/no-access renders using Filament UI (no raw HTML pages), with the title "No Access" and the message "Please contact an administrator for access.".
  2. The page does not leak internal details; it provides next steps (“Ask an admin to add you”).

Requirements (mandatory)

Functional Requirements

  • FR-001: /admin/login MUST offer a "Sign in with Microsoft" entrypoint that starts the Entra OIDC flow. (Entra-only removal of local/break-glass UX is finalized in Feature 064.)
  • FR-002: Tenant user upsert MUST be keyed by (entra_tenant_id, entra_object_id) and MUST NOT store Entra access/refresh tokens.
  • FR-003: Post-login routing MUST be based on memberships:
    • 0 → /admin/no-access
    • 1 → tenant dashboard
    • N → dedicated tenant chooser page, displaying tenant name and role for each option.
  • FR-004: OIDC failures MUST be handled safely:
    • redirect to /admin/login with generic error
    • log stable reason_code + correlation_id
    • never log token/claims payloads
  • FR-005: Logging MUST be privacy-safe:
    • success: minimal (user_id, tid, oid hash, timestamp, correlation_id)
    • failure: reason_code, correlation_id, minimal diagnostics
  • FR-006: /admin/login, /admin/no-access, and /admin/choose-tenant MUST be DB-only at render/hydration time (no outbound HTTP).
  • FR-007: 063 MUST NOT surface break-glass links or platform login UX on /admin/login.
  • FR-008: Session separation MUST prevent implicit crossover:
    • a tenant session MUST NOT grant access to /system
    • a platform session MUST NOT grant access to /admin tenant membership routes (Implementation is via separate guards/panels; 063 only asserts behavior via tests.)
  • FR-009: Login flow MUST NOT require queue workers; it must complete synchronously.
  • FR-010: If a user record exists but is in a disabled or soft-deleted state, a successful Entra ID authentication MUST be blocked, and the user redirected to the login page with a generic error.

Non-Functional Requirements (NFR)

  • NFR-01 (Security): Do not persist secrets/tokens. Sanitize all error output and logs.
  • NFR-02 (Stability): Callback is idempotent; safe to retry without creating duplicates.
  • NFR-03 (Performance): Callback returns within ~2s under normal conditions.
  • NFR-04 (Maintainability): Minimal diff; do not refactor membership/RBAC models.

Auth handshake exception to Operations/Run standard

The Operations/Run Observability standard applies to queued/long-running tenant operations (sync, restore, drift, provider ops). The Entra OIDC sign-in handshake is an interactive authentication flow that necessarily performs outbound HTTPS to Entra endpoints during the callback.

This auth handshake is explicitly exempt from the OperationRun requirement:

  • It is user-initiated and must complete synchronously to establish a session.
  • It does not represent a tenant-scoped background operation.
  • It must not enqueue jobs or perform remote work beyond the OIDC exchange.

Guardrail: Only the /auth/entra/* endpoints may perform outbound HTTP for OIDC. /admin/login, /admin/no-access, and /admin/choose-tenant remain DB-only at render/hydration time.

Data Model

063 reuses the existing users table.

Current repo state (authoritative for v1):

  • entra_tenant_id (varchar(255), nullable)
  • entra_object_id (varchar(255), nullable)
  • Unique composite index on (entra_tenant_id, entra_object_id) already exists.

v1 decision (enterprise-safe, minimal-risk):

  • Keep both columns nullable in v1.
  • Do not change column types (no uuid migration) in v1.
  • Do not enforce NOT NULL in v1.

Optional (if already present / used later):

  • last_tenant_id (nullable FK to tenants.id) to optimize redirect for multi-membership users.

No new tables required.

Routes / UI Surfaces

  • /admin/login — Filament login override (Entra-only CTA)
  • /auth/entra/redirect — starts OIDC redirect (Socialite)
  • /auth/entra/callback — handles callback, upsert, routing
  • /admin/no-access — Filament page for 0-membership users
  • /admin/choose-tenant — Filament page for N-membership users to select a tenant

OIDC Handling & Failure Semantics

Claims requirements

  • tid (tenant id) MUST be present.
  • oid (object id) MUST be present. If missing → fail safely.

Generic user-facing error

  • “Authentication failed. Please try again.”

Server-side logging

Log event auth.entra.login with:

  • success: boolean
  • reason_code: string (on failure)
  • user_id: (on success)
  • entra_tenant_id: tid (plaintext, for Ops correlation)
  • entra_object_id_hash: hash(oid)
  • correlation_id: request id or session id
  • timestamp

Reason code examples (stable)

  • oidc_missing_claims
  • oidc_invalid_state
  • oidc_user_denied
  • oidc_provider_unavailable
  • oidc_user_upsert_failed
  • user_disabled

Implementation Guardrails (hard)

  • Do not implement a password login form for /admin.
  • Do not call $this->form->fill() with default creds.
  • Do not show break-glass link/button on /admin/login.
  • Do not modify /system panel, platform guards, or break-glass logic.
  • Do not refactor User::tenants() or membership schema; use a small resolver/service to decide redirect.
  • Do not make outbound HTTP during render/hydration of /admin/login, /admin/no-access, or /admin/choose-tenant. Outbound HTTP is permitted only inside /auth/entra/* endpoints for the OIDC exchange.
  • Do not store raw claims or tokens.

Acceptance Tests (required)

Feature tests (Pest)

  1. AdminLoginIsEntraOnlyTest
    • GET /admin/login contains Microsoft CTA
    • asserts no password input / no local login form
  2. EntraCallbackUpsertByTidOidTest
    • callback upserts user by (tid, oid) (unique)
    • session is regenerated
  3. PostLoginRoutingByMembershipTest
    • 0 memberships → /admin/no-access
    • 1 membership → tenant dashboard
    • N memberships → chooser page
  4. OidcFailureRedirectsSafelyTest
    • missing tid/oid → redirect /admin/login
    • logs contain reason_code + correlation_id
    • logs do not contain tokens/claims dumps
  5. SessionSeparationSmokeTest
    • tenant session cannot access /system
    • platform session cannot access tenant membership routes without membership
  6. DisabledUserLoginIsBlockedTest
    • Seed a disabled/soft-deleted user
    • Fake a successful OIDC callback for that user
    • Assert redirect to /admin/login
    • Assert log contains reason_code: user_disabled

Quality gate

  • ./vendor/bin/sail bin pint --dirty
  • ./vendor/bin/sail artisan test tests/Feature/Auth --stop-on-failure

Manual Verification Checklist

  1. Open /admin/login - only Microsoft sign-in CTA visible
  2. Complete Entra sign-in - user record exists with tid/oid
  3. 0 memberships → /admin/no-access
  4. 1 membership → tenant dashboard
  5. 1 memberships → chooser page

  6. Verify logs:
    • failures show reason_code + correlation_id
    • no tokens/claims in logs

Out of Scope / Follow-ups

  • 064-auth-structure: /system operator login hardening, break-glass UX, panel separation governance.
  • 062-tenant-rbac-v1: role enforcement audit + resource-by-resource authorization hardening.
  • Advanced Entra topics (v2+): delegated flows, refresh token storage, certificate auth, conditional access UI.