up
	
		
			
	
		
	
	
		
	
		
			Some checks failed
		
		
	
	
		
			
				
	
				Build Test Deploy / docs (push) Has been cancelled
				
			
		
			
				
	
				Build Test Deploy / deploy (push) Has been cancelled
				
			
		
			
				
	
				Build Test Deploy / build-test (push) Has been cancelled
				
			
		
			
				
	
				Build Test Deploy / authority-container (push) Has been cancelled
				
			
		
			
				
	
				Docs CI / lint-and-preview (push) Has been cancelled
				
			
		
		
	
	
				
					
				
			
		
			Some checks failed
		
		
	
	Build Test Deploy / docs (push) Has been cancelled
				
			Build Test Deploy / deploy (push) Has been cancelled
				
			Build Test Deploy / build-test (push) Has been cancelled
				
			Build Test Deploy / authority-container (push) Has been cancelled
				
			Docs CI / lint-and-preview (push) Has been cancelled
				
			This commit is contained in:
		| @@ -1,46 +0,0 @@ | |||||||
| StellaOps Authority Project – Phased Execution Prompts |  | ||||||
|  |  | ||||||
| Teams: |  | ||||||
| - Team 1: DevEx / Platform (see AGENTS.md, StellaOps.Authority.TODOS.md, StellaOps.Authority.TODOS.DevEx.md) |  | ||||||
| - Team 2: Authority Core Service (see AGENTS.md, StellaOps.Authority.TODOS.md, StellaOps.Authority.TODOS.AuthorityCore.md) |  | ||||||
| - Team 3: Plugin Workstream (see AGENTS.md, StellaOps.Authority.TODOS.md, StellaOps.Authority.TODOS.Plugin.md) |  | ||||||
| - Team 4: Auth Libraries (see AGENTS.md, StellaOps.Authority.TODOS.md, StellaOps.Authority.TODOS.AuthLibraries.md) |  | ||||||
| - Team 5: Feedser Integration (see AGENTS.md, StellaOps.Authority.TODOS.md, StellaOps.Authority.TODOS.Feedser.md) |  | ||||||
| - Team 6: CLI (see AGENTS.md, StellaOps.Authority.TODOS.md, StellaOps.Authority.TODOS.CLI.md) |  | ||||||
| - Team 7: DevOps / Observability (see AGENTS.md, StellaOps.Authority.TODOS.md, StellaOps.Authority.TODOS.DevOps.md) |  | ||||||
| - Team 8: Security Guild (see AGENTS.md, StellaOps.Authority.TODOS.md, StellaOps.Authority.TODOS.Security.md) |  | ||||||
| - Team 9: Docs & Enablement (see AGENTS.md, StellaOps.Authority.TODOS.md, StellaOps.Authority.TODOS.Docs.md) |  | ||||||
|  |  | ||||||
| Phase 0 – Bootstrapping |  | ||||||
| - Prompt Team 1: “Read AGENTS.md, StellaOps.Authority.TODOS.md, and StellaOps.Authority.TODOS.DevEx.md. Complete FND1 → FND3 (solution scaffold, build props, AuthorityOptions binding). Report when the Authority solution builds clean.” |  | ||||||
| - Wait until FND1–FND3 are DONE before continuing. |  | ||||||
|  |  | ||||||
| Phase 1 – Core Foundations |  | ||||||
| - Prompt Team 1: “Continue with StellaOps.Authority.TODOS.DevEx.md. Deliver FND4, FND5, and PLG5 (config samples, telemetry constants, plugin config loader).” |  | ||||||
| - Prompt Team 2: “Read AGENTS.md, StellaOps.Authority.TODOS.md, and StellaOps.Authority.TODOS.AuthorityCore.md. Implement CORE1 + CORE2 (minimal API host, OpenIddict endpoints). Verify /health and /ready before proceeding.” |  | ||||||
| - Prompt Team 3: “Read AGENTS.md, StellaOps.Authority.TODOS.md, and StellaOps.Authority.TODOS.Plugin.md. Execute PLG1–PLG3 (abstractions, plugin loader integration, Mongo-based Standard plugin stub). Coordinate schema details with Team 1.” |  | ||||||
| - Do not start Phase 2 until Team 2 finishes CORE1–CORE2 and Team 3 finishes PLG1–PLG3. |  | ||||||
|  |  | ||||||
| Phase 2 – Core Expansion & Libraries |  | ||||||
| - Prompt Team 2: “Continue with StellaOps.Authority.TODOS.AuthorityCore.md tasks CORE3–CORE6 (Mongo stores, plugin capability wiring, bootstrap admin APIs).” |  | ||||||
| - Prompt Team 3: “Advance PLG4–PLG6 (capability metadata, config validation, plugin developer guide draft).” |  | ||||||
| - Prompt Team 4: “Read AGENTS.md, StellaOps.Authority.TODOS.md, and StellaOps.Authority.TODOS.AuthLibraries.md. Deliver LIB1–LIB4 (abstractions, NetworkMaskMatcher, ServerIntegration DI, Auth.Client).” |  | ||||||
| - Move to Phase 3 only after CORE3–CORE6, PLG4–PLG6, and LIB1–LIB4 are DONE. |  | ||||||
|  |  | ||||||
| Phase 3 – Integration & Ops |  | ||||||
| - Prompt Team 2: “Finish CORE7–CORE10 (telemetry, rate limiting, revocation list, key rotation/JWKS).” |  | ||||||
| - Prompt Team 3: “Complete PLG6 handoff and draft PLG7 RFC if bandwidth allows.” |  | ||||||
| - Prompt Team 4: “Implement LIB5–LIB6 (Polly integration, packaging metadata).” |  | ||||||
| - Prompt Team 5: “Read AGENTS.md, StellaOps.Authority.TODOS.md, and StellaOps.Authority.TODOS.Feedser.md. Execute FSR1–FSR3 (config, auth wiring, bypass masks) then FSR4 docs updates.” |  | ||||||
| - Prompt Team 6: “Read AGENTS.md, StellaOps.Authority.TODOS.md, and StellaOps.Authority.TODOS.CLI.md. Deliver CLI1–CLI4 (config, auth commands, bearer injection, docs).” |  | ||||||
| - Prompt Team 7: “Read AGENTS.md, StellaOps.Authority.TODOS.md, and StellaOps.Authority.TODOS.DevOps.md. Execute OPS1–OPS5 (Dockerfile/compose, CI pipeline, key rotation tooling, backup docs, monitoring).” |  | ||||||
| - Prompt Team 9: “Read AGENTS.md, StellaOps.Authority.TODOS.md, and StellaOps.Authority.TODOS.Docs.md. Draft DOC1–DOC4 in parallel; update DOC5 once Feedser/CLI changes land.” |  | ||||||
| - Proceed to Phase 4 only when CORE10, PLG6, LIB6, FSR4, CLI4, OPS5, and DOC4 are complete. |  | ||||||
|  |  | ||||||
| Phase 4 – Security & Final Integration |  | ||||||
| - Prompt Team 8: “Read AGENTS.md, StellaOps.Authority.TODOS.md, and StellaOps.Authority.TODOS.Security.md. Execute SEC1–SEC5 (password hashing, audit log review, lockout/rate-limit validation, revocation signing, threat model). Review Feedser/CLI for security compliance.” |  | ||||||
| - Prompt Team 5: “Run FSR5 (Authority ↔ Feedser integration tests) using the DevOps compose stack.” |  | ||||||
| - Prompt Team 6: “Finalize CLI auth enhancements and ensure tests reflect Security feedback.” |  | ||||||
| - Prompt Team 7: “Support integration testing, finalize runbooks, confirm monitoring dashboards.” |  | ||||||
| - Prompt Team 9: “Incorporate Security findings, finalize DOC3 migration guide, DOC5 README/quickstart updates, release notes.” |  | ||||||
| - Wrap up after SEC5 sign-off and successful FSR5 execution. |  | ||||||
| @@ -1,42 +0,0 @@ | |||||||
| # StellaOps Authority — Authentication Libraries Team |  | ||||||
|  |  | ||||||
| > **Read first:** `AGENTS.md`, `StellaOps.Authority.TODOS.md`, and this plan. Keep status synchronized across trackers. |  | ||||||
|  |  | ||||||
| ## Mission |  | ||||||
| Deliver shared authentication components consumed by resource servers, clients, and tooling: abstractions, DI helpers, token clients, and supporting utilities. |  | ||||||
|  |  | ||||||
| ## Task Breakdown |  | ||||||
|  |  | ||||||
| | Order | Task IDs | Description | Dependencies | Acceptance | |  | ||||||
| |-------|----------|-------------|--------------|------------| |  | ||||||
| | 1 | LIB1 | Stand up `StellaOps.Auth.Abstractions` (claims, scopes, principal builder, ProblemResultFactory). | DevEx FND1 | Unit tests covering claim normalization + problem responses. | |  | ||||||
| | 2 | LIB3 | Implement `NetworkMaskMatcher` with IPv4/IPv6 CIDR support; port tests from Serdica inspiration. | LIB1 | 100% branch coverage on mask utilities. | |  | ||||||
| | 3 | LIB2 | Build `StellaOps.Auth.ServerIntegration` (DI extension wiring JwtBearer, bypass masks, policy helpers). | LIB1, LIB3 | Add integration test with stub Authority JWKS. | |  | ||||||
| | 4 | LIB4 | Build `StellaOps.Auth.Client` (discovery, JWKS caching, password/client credential flows, token cache abstraction). | LIB1 | Provide `IStellaOpsTokenClient` interfaces. | |  | ||||||
| | 5 | LIB5 | Integrate Polly + HttpClientFactory patterns (configurable retries/backoff) in Auth.Client. | LIB4 | Config tested via options binding. | |  | ||||||
| | 6 | LIB6 | Prepare NuGet packaging metadata (license, tags) and update build pipeline to push once stabilized. | LIB1–LIB5 | Validate `dotnet pack` outputs signed packages. | |  | ||||||
|  |  | ||||||
| ## Implementation Notes |  | ||||||
| - All option classes should bind via `StellaOps.Configuration` naming conventions.   |  | ||||||
| - Token client must support file-based cache (for CLI) and in-memory cache (for services).   |  | ||||||
| - Provide sample usage snippets for Feedser integration (to hand off).   |  | ||||||
| - Consider adding `IClaimsTransformation` helper for ASP.NET resource servers.   |  | ||||||
| - Ensure authentication failures map to standard problem responses (missing/expired token, insufficient scope). |  | ||||||
|  |  | ||||||
| ## Deliverables |  | ||||||
| - Three new projects: `StellaOps.Auth.Abstractions`, `.ServerIntegration`, `.Client`.   |  | ||||||
| - Unit + integration tests, coverage reports.   |  | ||||||
| - Example integration docs/snippets for Feedser and CLI teams.   |  | ||||||
| - Packaging metadata ready for CI once green-lit. |  | ||||||
|  |  | ||||||
| ## Coordination |  | ||||||
| - Weekly sync with Authority Core + Feedser Integration to align on scopes/policies.   |  | ||||||
| - Share NuGet package versions with DevEx once published.   |  | ||||||
| - Notify CLI team when client API stabilizes (unlock CLI1–CLI3).   |  | ||||||
| - Coordinate with Security Guild on bypass mask semantics and default policies. |  | ||||||
|  |  | ||||||
| ## Status (2025-10-10) |  | ||||||
| - LIB1 DONE – Principal builder/problem factory complete with unit coverage. |  | ||||||
| - LIB3 DONE – `NetworkMaskMatcher` replaces Serdica helpers with IPv4/6 tests. |  | ||||||
| - LIB2 DONE – `AddStellaOpsResourceServerAuthentication` with scope/bypass policies implemented. |  | ||||||
| - LIB4 DONE – Auth client, discovery/JWKS caches, in-memory/file token caches with happy-path tests delivered. |  | ||||||
| @@ -1,56 +0,0 @@ | |||||||
| # StellaOps Authority — Core Service Team |  | ||||||
|  |  | ||||||
| > **Read first:** `AGENTS.md`, `StellaOps.Authority.TODOS.md`, and this plan. Update status in both TODO trackers. |  | ||||||
|  |  | ||||||
| ## Mission |  | ||||||
| Design and implement the Authority host (OpenIddict server, token lifecycles, administrative endpoints) on top of the DevEx scaffold, coordinating with Plugin, Library, and Security teams. |  | ||||||
|  |  | ||||||
| ## Work Breakdown |  | ||||||
|  |  | ||||||
| | Order | Task IDs | Description | Dependencies | Acceptance | |  | ||||||
| |-------|----------|-------------|--------------|------------| |  | ||||||
| | 1 | CORE1 | Wire minimal API host with configuration, logging, plugin discovery, `/health` + `/ready`. | DevEx FND1–FND5 | Manual smoke: `dotnet run` returns healthy responses. | |  | ||||||
| | 2 | CORE2 | Configure OpenIddict server endpoints & flows (password, client credentials, refresh, jwks). | CORE1 | Supports HTTPS enforcement toggle via options. | |  | ||||||
| | 3 | CORE3 | Implement Mongo repositories for users/clients/scopes/tokens/login attempts. | CORE1 | Collections + indices documented; unit tests for CRUD. | |  | ||||||
| | 4 | CORE4 | Integrate plugin contracts (`IIdentityProviderPlugin`, etc.) into DI; load capabilities. | PLG1 | Plugins registered through host on startup. | |  | ||||||
| | 5 | CORE5 | Port/customize OpenIddict handlers (password/client creds validation) to use plugin contracts. | CORE4 | Unit tests for success/failure scenarios. | |  | ||||||
| | 5a | CORE5A | Add integration tests covering token persistence & revocation via `IAuthorityTokenStore`. | CORE5 | Ensure revoked tokens denied + fixtures for access/reference tokens. | |  | ||||||
| | 5b | CORE5B | Document token persistence & enrichment flows for resource servers/plugins. | CORE5 | Docs updated with claim expectations + revocation sync guidance. | |  | ||||||
| | 6 | CORE6 | Implement bootstrap admin endpoints (`/internal/users`, `/internal/clients`) secured via bootstrap API key. | CORE5 | Add rate limiting + audit logs. | |  | ||||||
| | 7 | CORE7 & CORE8 | Add structured logging, OTEL spans, and ASP.NET rate limiting for `/token`, `/authorize`. | CORE5 | Verify via integration tests, metrics exported. | |  | ||||||
| | 8 | CORE9 | Implement token revocation + signed offline revocation manifest generation hooks. | CORE5 | CLI call returns signed JSON; tests confirm revoked tokens denied. | |  | ||||||
| | 9 | CORE10 | Configure signing/encryption key rotation, JWKS publishing, certificate loader. | CORE5 | Document rotation steps; integration test covers key rollover. | |  | ||||||
|  |  | ||||||
| ## Implementation Notes |  | ||||||
| - All Mongo repositories must align with offline-first design (no TTL for critical data unless configurable).   |  | ||||||
| - Expose metrics counters (issued tokens, failed attempts) for DevOps consumption.   |  | ||||||
| - Coordinate with Security Guild for password hashing options (Argon2 vs PBKDF2), lockout thresholds.   |  | ||||||
| - Ensure plugin capability metadata is honored (e.g., if plugin lacks password support, reject password grants gracefully).   |  | ||||||
| - Provide integration hooks for future LDAP plugin (capability flag + TODO comment). |  | ||||||
|  |  | ||||||
| ## Status |  | ||||||
|  |  | ||||||
| - [x] CORE1 – Completed 2025-10-09. Minimal API host loads validated configuration, configures Serilog, registers plugins, and exposes `/health` + `/ready`. |  | ||||||
| - [x] CORE2 – Completed 2025-10-09. OpenIddict server configured with required endpoints, token lifetimes, sliding refresh tokens, and Development-only HTTPS relaxation. |  | ||||||
| - [x] CORE3 – Completed 2025-10-09. Mongo storage project created with indexed Authority collections, repositories, and bootstrap migration runner. |  | ||||||
| - [ ] CORE4 – Not started. |  | ||||||
| - [x] CORE5 – Completed 2025-10-10 with client-credentials validation, token validation handlers, and token persistence wired through plugin contracts. |  | ||||||
| - [ ] CORE5A – Pending integration tests for token persistence/revocation behaviour (QA + BE-Auth pairing). |  | ||||||
| - [ ] CORE5B – Pending documentation refresh covering claims enrichment + token store expectations. |  | ||||||
| - [x] CORE6 – Completed 2025-10-10. Bootstrap admin APIs behind API key provison users and clients through plugin stores. |  | ||||||
| - [ ] CORE7 – Not started. |  | ||||||
| - [ ] CORE8 – Not started. |  | ||||||
| - [ ] CORE9 – Not started. |  | ||||||
| - [ ] CORE10 – Not started. |  | ||||||
|  |  | ||||||
| ## Deliverables |  | ||||||
| - `StellaOps.Authority` project with tested endpoints and handlers.   |  | ||||||
| - Repository docs summarizing API responses (shared with Docs team).   |  | ||||||
| - Integration tests (Authority-only) verifying token issuance + revocation.   |  | ||||||
| - Audit logging implemented (structured with trace IDs). |  | ||||||
|  |  | ||||||
| ## Coordination |  | ||||||
| - Daily stand-up with Plugin + Libraries teams until CORE5 complete (met objective 2025-10-10).   |  | ||||||
| - Notify DevOps when `/token` contract stabilizes (OPS pipeline).   |  | ||||||
| - Work with Docs to capture endpoint behavior for `docs/11_AUTHORITY.md`.   |  | ||||||
| - Review PRs from Plugin & Libraries teams affecting Authority host. |  | ||||||
| @@ -1,35 +0,0 @@ | |||||||
| # StellaOps Authority — CLI Team |  | ||||||
|  |  | ||||||
| > **Read first:** `AGENTS.md`, `StellaOps.Authority.TODOS.md`, and this plan. Keep status aligned in all trackers. |  | ||||||
|  |  | ||||||
| ## Mission |  | ||||||
| Enable `stellaops-cli` to authenticate against StellaOps Authority, manage tokens, and surface auth-related UX for operators. |  | ||||||
|  |  | ||||||
| ## Task Queue |  | ||||||
|  |  | ||||||
| | Order | Task IDs | Description | Dependencies | Acceptance | |  | ||||||
| |-------|----------|-------------|--------------|------------| |  | ||||||
| | 1 | CLI1 | Extend `StellaOpsCliOptions` and configuration bootstrap to include Authority settings (AuthorityUrl, ClientId/Secret, Username/Password). | LIB4 | **DONE (2025-10-10)** – Options bind authority fields, env fallbacks documented, and cache directory defaults to `~/.stellaops/tokens`. | |  | ||||||
| | 2 | CLI2 | Implement `auth` command group (`login`, `logout`, `status`) using `StellaOps.Auth.Client`. | CLI1, LIB4 | **DONE (2025-10-10)** – Commands support client-credentials/password flows, force re-auth, and surface status output. | |  | ||||||
| | 3 | CLI3 | Ensure all backend calls attach bearer tokens; handle 401/403 with clear messaging and retry guidance. | CLI2, LIB2 | **DONE (2025-10-10)** – Backend client now resolves cached tokens via shared helper and attaches Authorization headers on every call. | |  | ||||||
| | 4 | CLI4 | Update help text and docs (quickstart + API reference) to describe new auth workflow. | CLI1–CLI3 | Coordinate with Docs team for final copy. | |  | ||||||
| | 5 | OPTIONAL | Add `auth whoami` to display token scopes/expiry (post-MVP if time allows). | CLI2 | Non-blocking enhancement. | |  | ||||||
|  |  | ||||||
| ## Implementation Notes |  | ||||||
| - Token cache path defaults to `~/.stellaops/tokens`; allow override via config.   |  | ||||||
| - Handle offline mode gracefully (cached token reuse, helpful errors).   |  | ||||||
| - Provide verbose logging around token acquisition (without dumping secrets).   |  | ||||||
| - Support non-interactive mode (env vars) for CI pipelines.   |  | ||||||
| - Align CLI exit codes with backend problem types (401 -> exit 10, etc.). |  | ||||||
|  |  | ||||||
| ## Deliverables |  | ||||||
| - Updated CLI project + tests.   |  | ||||||
| - Docs/help updates referencing Authority integration.   |  | ||||||
| - Sample command snippets for operators (login, job trigger with scope).   |  | ||||||
| - Changelog entry describing auth changes. |  | ||||||
|  |  | ||||||
| ## Coordination |  | ||||||
| - Collaborate with Auth Libraries team to stabilize client API.   |  | ||||||
| - Sync with Feedser integration to ensure required scopes align.   |  | ||||||
| - Provide feedback to Authority Core on error payloads for better CLI UX.   |  | ||||||
| - Work with Docs team for documentation rollout. |  | ||||||
| @@ -1,35 +0,0 @@ | |||||||
| # StellaOps Authority — DevEx / Platform Workstream |  | ||||||
|  |  | ||||||
| > **Read first:** `AGENTS.md`, `StellaOps.Authority.TODOS.md`, and this file.   |  | ||||||
| > Keep task status synced in both TODO trackers whenever items move (TODO → DOING → DONE/BLOCKED). |  | ||||||
|  |  | ||||||
| ## Scope |  | ||||||
| - Repository scaffolding, shared configuration plumbing, sample configs, telemetry constants.   |  | ||||||
| - Provide the baseline everyone else builds on; unblock quickly, announce breaking changes on the shared channel. |  | ||||||
|  |  | ||||||
| ## Deliverables & Checklist |  | ||||||
|  |  | ||||||
| | Order | Task ID | Description | Dependencies | Notes | |  | ||||||
| |-------|---------|-------------|--------------|-------| |  | ||||||
| | 1 | FND1 | Create `src/StellaOps.Authority` solution layout (Authority host, Plugins.Abstractions, Plugin.Standard stub, Auth libraries). | none | **DONE** – Solution scaffolding live with net10.0 preview defaults + project references. | |  | ||||||
| | 2 | FND2 | Update repository build props/targets for new projects; ensure analyzers + nullable + treat warnings as errors. | FND1 | **DONE** – Directory.Build props/targets extended; root `StellaOps.sln` added (root build still surfaced existing Feedser compile failures). | |  | ||||||
| | 3 | FND3 | Extend `StellaOps.Configuration` with `StellaOpsAuthorityOptions`, binder, validation stubs. | FND1 | **DONE** – Options schema + bootstrap helper + unit tests validating binding/normalisation. | |  | ||||||
| | 4 | FND4 | Publish `etc/authority.yaml.sample` (with plugin toggles) + README mention. | FND3 | **DONE** – Sample config added with env var guidance; README + quickstart updated. | |  | ||||||
| | 5 | FND5 | Register OTEL resource constants (service.name = `stellaops-authority`, etc.). | FND3 | **DONE** – Authority telemetry constants helper published for shared use. | |  | ||||||
| | 6 | PLG5 | Define plugin config directory structure (`etc/authority.plugins/*.yaml`), loader helpers, sample files. | FND3 | **DONE** – Schema + loader shipped, standard/ldap samples published. | |  | ||||||
| | 7 | OPS1 (support) | Pair with DevOps on Dockerfile/compose scaffolding to ensure directories, config names match. | FND4 | **DONE** – Provided distroless Dockerfile/compose guidance in `ops/authority/` for DevOps handoff. | |  | ||||||
|  |  | ||||||
| ### Exit Criteria |  | ||||||
| - `dotnet build` succeeds from repo root with new projects.   |  | ||||||
| - Configuration sample + docs referenced in README/Authority TODO file.   |  | ||||||
| - Telemetry/resource constants ready for Authority Core team.   |  | ||||||
| - Plugin config loader available before Plugin Team begins feature work. |  | ||||||
|  |  | ||||||
| ### Risks / Mitigations |  | ||||||
| - **Risk:** Build props drift. → Run `dotnet format --verify-no-changes` before handoff.   |  | ||||||
| - **Risk:** Config breaking changes mid-implementation. → Version `StellaOpsAuthorityOptions` and communicate via Slack + TODO updates. |  | ||||||
|  |  | ||||||
| ### Coordination |  | ||||||
| - Daily async update until FND3 complete.   |  | ||||||
| - Hand off AuthorityOptions schema to all other teams once finalized (tag repository issue).   |  | ||||||
| - Keep an eye on PR queue—DevEx reviews required for structure/config changes. |  | ||||||
| @@ -1,36 +0,0 @@ | |||||||
| # StellaOps Authority — DevOps & Observability Team |  | ||||||
|  |  | ||||||
| > **Read first:** `AGENTS.md`, `StellaOps.Authority.TODOS.md`, and this plan. Reflect status changes in both TODO trackers. |  | ||||||
|  |  | ||||||
| ## Mission |  | ||||||
| Deliver deployable artefacts, CI/CD automation, runtime observability, and operational runbooks for StellaOps Authority. |  | ||||||
|  |  | ||||||
| ## Task Matrix |  | ||||||
|  |  | ||||||
| | Order | Task IDs | Description | Dependencies | Acceptance | |  | ||||||
| |-------|----------|-------------|--------------|------------| |  | ||||||
| | 1 | OPS1 | Author distroless Dockerfile + docker-compose sample (Authority + Mongo + optional Redis). | FND4, CORE1 | **DONE (DevEx scaffold)** – see `ops/authority/` Dockerfile + compose; verify with production secrets before release. | |  | ||||||
| | 2 | OPS2 | Extend CI workflows (build/test/publish) for Authority + auth libraries (dotnet build/test, docker build, artefact publish). | OPS1 | **DONE** – Authority build/test/publish integrated into `.gitea/workflows/build-test-deploy.yml`. | |  | ||||||
| | 3 | OPS3 | Implement key rotation script/CLI and wire pipeline job (manual trigger) to rotate signing keys + update JWKS. | CORE10 | Document rotation process + store secrets securely. | |  | ||||||
| | 4 | OPS4 | Document backup/restore for Authority Mongo collections, plugin configs, key material. | CORE3 | Produce runbook in `/docs/ops`. | |  | ||||||
| | 5 | OPS5 | Define monitoring metrics/alerts (token issuance failure rate, lockout spikes, bypass usage). Provide dashboards (Prometheus/Otel). | CORE7 | Share Grafana JSON or equivalent. | |  | ||||||
| | 6 | SUPPORT | Assist other teams with docker-compose variations for integration tests (Feedser, CLI). | OPS1, FSR5 | Provide templates + guidance. | |  | ||||||
|  |  | ||||||
| ## Implementation Notes |  | ||||||
| - Container image must remain offline-friendly (no package installs at runtime).   |  | ||||||
| - Compose sample should include environment variable settings referencing `etc/authority.yaml`.   |  | ||||||
| - Store key rotation artefacts in secure storage (vault/secrets).   |  | ||||||
| - Align metrics naming with existing StellaOps conventions.   |  | ||||||
| - Provide fallback instructions for air-gapped deployments (manual image load, offline key rotation). |  | ||||||
|  |  | ||||||
| ## Deliverables |  | ||||||
| - Dockerfile(s), compose stack, and documentation.   |  | ||||||
| - Updated CI pipeline definitions.   |  | ||||||
| - Runbooks for rotation, backup, restore.   |  | ||||||
| - Monitoring/alerting templates. |  | ||||||
|  |  | ||||||
| ## Coordination |  | ||||||
| - Sync with DevEx on configuration paths + plugin directories.   |  | ||||||
| - Coordinate with Authority Core regarding key management endpoints.   |  | ||||||
| - Work with Feedser Integration + CLI teams on integration test environments.   |  | ||||||
| - Engage Security Guild to review key rotation + secret storage approach. |  | ||||||
| @@ -1,36 +0,0 @@ | |||||||
| # StellaOps Authority — Docs & Enablement Plan |  | ||||||
|  |  | ||||||
| > **Read first:** `AGENTS.md`, `StellaOps.Authority.TODOS.md`, and this plan. Keep progress synchronized across trackers. |  | ||||||
|  |  | ||||||
| ## Mission |  | ||||||
| Produce operator and developer documentation for the new Authority stack, including configuration guides, API references, plugin tutorials, migration playbooks, and release notes. |  | ||||||
|  |  | ||||||
| ## Task Pipeline |  | ||||||
|  |  | ||||||
| | Order | Task IDs | Description | Dependencies | Acceptance | |  | ||||||
| |-------|----------|-------------|--------------|------------| |  | ||||||
| | 1 | DOC1 | Draft `docs/11_AUTHORITY.md` (architecture overview, configuration, plugin model, deployment scenarios). | FND4, CORE1 | Reviewed by DevEx + Authority Core. | |  | ||||||
| | 2 | DOC2 | Generate API reference snippets for `/token`, `/jwks`, `/introspect`, `/revoke` (OpenAPI fragment + human-readable table). | CORE2, LIB4 | Linked from docs + README. | |  | ||||||
| | 3 | DOC3 | Write migration guide for Feedser moving from anonymous to secured mode (staged rollout, config updates). | FSR1–FSR3 | Includes rollback plan + FAQ. | |  | ||||||
| | 4 | DOC4 | Create plugin developer how-to (leveraging Plugin Team notes) covering packaging, capability flags, logging. | PLG1–PLG6 | **READY FOR DOCS REVIEW (2025-10-10)** – `docs/dev/31_AUTHORITY_PLUGIN_DEVELOPER_GUIDE.md` aligned with PLG6 scope; pending Docs copy-edit, diagram export, and LDAP RFC cross-linking. | |  | ||||||
| | 5 | DOC5 | Update root README, quickstarts (`docs/10_FEEDSER_CLI_QUICKSTART.md`), CLI help text references. | CLI4, FSR4 | Make sure new links validated. | |  | ||||||
| | 6 | Cross | Collaborate on inline XML docs for public APIs across libraries. | LIB1–LIB5 | Ensure DocFX/IntelliSense friendly summaries. | |  | ||||||
|  |  | ||||||
| ## Implementation Notes |  | ||||||
| - Maintain offline-friendly instructions (no implicit internet requirements).   |  | ||||||
| - Highlight security-critical steps (bootstrap credentials, key rotation) in callouts.   |  | ||||||
| - Include environment-variable tables for configuration.   |  | ||||||
| - Provide diagrams where useful (architecture, plugin flow).   |  | ||||||
| - Prepare release note entry summarizing Authority MVP deliverables and upgrade steps. |  | ||||||
|  |  | ||||||
| ## Deliverables |  | ||||||
| - New documentation pages + updated existing guides.   |  | ||||||
| - OpenAPI snippet (JSON/YAML) committed to repo.   |  | ||||||
| - Migration checklist for operators.   |  | ||||||
| - Plugin developer tutorial ready for community/internal teams. |  | ||||||
|  |  | ||||||
| ## Coordination |  | ||||||
| - Attend cross-team syncs to capture latest API contracts.   |  | ||||||
| - Request reviews from respective teams (Authority Core, Plugin, CLI, Security).   |  | ||||||
| - Work with DevEx to ensure docs packaged in Offline Kit if applicable.   |  | ||||||
| - Update docs as soon as breaking changes occur—subscribe to relevant PRs. |  | ||||||
| @@ -1,35 +0,0 @@ | |||||||
| # StellaOps Authority — Feedser Integration Team |  | ||||||
|  |  | ||||||
| > **Read first:** `AGENTS.md`, `StellaOps.Authority.TODOS.md`, and this plan. Update both TODO trackers as tasks progress. |  | ||||||
|  |  | ||||||
| ## Mission |  | ||||||
| Adopt the new authority stack inside Feedser: configure authentication, enforce scopes, update configuration, and validate end-to-end flows. |  | ||||||
|  |  | ||||||
| ## Task Timeline |  | ||||||
|  |  | ||||||
| | Order | Task IDs | Description | Dependencies | Acceptance | |  | ||||||
| |-------|----------|-------------|--------------|------------| |  | ||||||
| | 1 | FSR1 | Extend `etc/feedser.yaml` with Authority configuration block (issuer, client credentials, bypass masks, scopes). | DevEx FND4, LIB2 | Sample config + docs updated. | |  | ||||||
| | 2 | FSR2 | Update Feedser WebService startup to use `AddStellaOpsResourceServerAuthentication`; annotate endpoints with `[Authorize]` and scope policies. | LIB2 | **DONE (2025-10-10)** – Auth wiring is optional but enabled via config; `/jobs*` endpoints demand `feedser.jobs.trigger` and tests cover bypass mode. | |  | ||||||
| | 3 | FSR3 | Implement bypass mask handling for on-host cron jobs; log when mask used. | FSR2, LIB3 | Configurable via YAML; integration test ensures mask respected. | |  | ||||||
| | 4 | FSR4 | Refresh Feedser docs (quickstart, operator guide) to explain auth requirements + config knobs. | FSR1–FSR3 | Coordinate with Docs team for final wording. | |  | ||||||
| | 5 | FSR5 | Build integration test harness (Authority + Feedser docker-compose) verifying token issuance and job triggering. | CORE1–CORE5, LIB4 | CI job produces pass/fail artefact. | |  | ||||||
|  |  | ||||||
| ## Implementation Notes |  | ||||||
| - Add feature flag to allow temporary anonymous mode for staged rollout (document sunset date).   |  | ||||||
| - Ensure CLI + API docs reference required scopes and sample client creation.   |  | ||||||
| - Logs should capture client ID, user ID, and scopes when jobs triggered for audit (without leaking secrets).   |  | ||||||
| - Avoid coupling tests to specific plugin implementations—use Standard plugin via configuration.   |  | ||||||
| - Share any new scopes/policies with Auth Libraries and Docs teams. |  | ||||||
|  |  | ||||||
| ## Deliverables |  | ||||||
| - Updated Feedser configuration + startup code.   |  | ||||||
| - Documentation updates in `docs/10_FEEDSER_CLI_QUICKSTART.md` and `docs/11_AUTHORITY.md` (in partnership with Docs team).   |  | ||||||
| - Integration tests executed in CI (Authority + Feedser).   |  | ||||||
| - Rollout checklist for existing deployments (feature flag, config changes). |  | ||||||
|  |  | ||||||
| ## Coordination |  | ||||||
| - Sync with Authority Core on policy naming (`feedser.jobs.trigger`, `feedser.merge`).   |  | ||||||
| - Coordinate with CLI team for shared sample configs.   |  | ||||||
| - Work closely with DevOps to integrate integration tests into pipeline.   |  | ||||||
| - Notify Security Guild once bypass masks implemented for review. |  | ||||||
| @@ -1,38 +0,0 @@ | |||||||
| # StellaOps Authority — Plugin Workstream |  | ||||||
|  |  | ||||||
| > **Read first:** `AGENTS.md`, `StellaOps.Authority.TODOS.md`, and this document. Sync status across all trackers. |  | ||||||
|  |  | ||||||
| ## Scope |  | ||||||
| Deliver the plugin abstraction layer and the default Mongo-backed identity plugin (`StellaOps.Authority.Plugin.Standard`), plus lay groundwork for future LDAP integration. |  | ||||||
|  |  | ||||||
| ## Task Plan |  | ||||||
|  |  | ||||||
| | Order | Task IDs | Description | Dependencies | Acceptance | |  | ||||||
| |-------|----------|-------------|--------------|------------| |  | ||||||
| | 1 | PLG1 | Implement plugin abstractions: `IIdentityProviderPlugin`, `IUserCredentialStore`, `IClaimsEnricher`, `IClientProvisioningStore`, result models, constants. | DevEx FND1 | **DONE** – Abstractions published with XML docs and unit tests covering success/failure factories. | |  | ||||||
| | 2 | PLG2 | Integrate abstractions with plugin host (DI registration via `IAuthorityPluginRegistrar`). Emit diagnostics for load failures. | PLG1 | **DONE** – Authority host loads registrars, logs registration summary, and unit tests cover success/missing cases. | |  | ||||||
| | 3 | PLG3 | Build Mongo-backed `Plugin.Standard` implementing password auth, lockout, claim enrichment, admin seeding. | CORE3 | **DONE** – Standard plugin binds options, enforces password policy/lockout, seeds bootstrap user, and ships integration/unit tests. | |  | ||||||
| | 4 | PLG4 | Define capability metadata (supportsPassword, supportsMfa, supportsClientProvisioning). Update plugin registration to publish metadata. | PLG3 | **DONE (2025-10-10)** – Capability descriptors validated; Standard plugin enforces password flag and registry exposes aggregated metadata to the host. | |  | ||||||
| | 5 | PLG5 (support) | Collaborate with DevEx on plugin config schema (`etc/authority.plugins/*.yaml`). Implement config parser + validation. | DevEx PLG5 | Provide typed options class + tests. | |  | ||||||
| | 6 | PLG6 | Author plugin developer guide (structure, packaging, capability flags, logging expectations). | PLG1–PLG5 | **READY FOR DOCS REVIEW (2025-10-10)** – Guide finalised, includes capability metadata usage, ops alignment, and packaging checklist; handoff blocked only on Docs copy-edit + diagram export. | |  | ||||||
| | 7 | PLG7 (backlog design) | Produce technical RFC for future `Plugin.Ldap` (data flows, dependencies, TODO list). | PLG1–PLG4 | **RFC DRAFTED (2025-10-10)** – `docs/rfcs/authority-plugin-ldap.md` outlines architecture, configuration schema, implementation plan; awaiting guild review & sign-off. | |  | ||||||
|  |  | ||||||
| ## Implementation Notes |  | ||||||
| - Mongo plugin must support offline bootstrap: optional JSON file with initial users/clients hashed offline.   |  | ||||||
| - Provide extensibility points for password hashing algorithm (allow Security team to swap Argon2).   |  | ||||||
| - Ensure plugin logging leverages Authority logger, no console writes.   |  | ||||||
| - Document expected configuration keys for plugin settings (`passwordPolicy`, `seedUsers`, etc.).   |  | ||||||
| - Validate plugin configuration early at startup; fail fast with actionable errors. |  | ||||||
|  |  | ||||||
| ## Deliverables |  | ||||||
| - `StellaOps.Authority.Plugins.Abstractions` project.   |  | ||||||
| - `StellaOps.Authority.Plugin.Standard` project with tests + seed data sample.   |  | ||||||
| - Plugin dev documentation + sample configuration files.   |  | ||||||
| - Diagnostic logging verifying plugin load, capabilities, configuration.   |  | ||||||
| - Future plugin RFC for LDAP integration. |  | ||||||
|  |  | ||||||
| ## Coordination |  | ||||||
| - Coordinate with Authority Core for capability usage in handlers.   |  | ||||||
| - Work with Security Guild on password hash settings/lockout thresholds.   |  | ||||||
| - Notify DevEx when configuration schema changes.   |  | ||||||
| - Review Docs PR for plugin developer guide. |  | ||||||
| @@ -1,36 +0,0 @@ | |||||||
| # StellaOps Authority — Security Guild Plan |  | ||||||
|  |  | ||||||
| > **Read first:** `AGENTS.md`, `StellaOps.Authority.TODOS.md`, and this plan. Track progress in both TODO files. |  | ||||||
|  |  | ||||||
| ## Mission |  | ||||||
| Define and verify the security posture of StellaOps Authority: password/secret policies, audit logging, throttling, threat modelling, and offline revocation guarantees. |  | ||||||
|  |  | ||||||
| ## Task Breakdown |  | ||||||
|  |  | ||||||
| | Order | Task IDs | Description | Dependencies | Acceptance | |  | ||||||
| |-------|----------|-------------|--------------|------------| |  | ||||||
| | 1 | SEC1 | Select and configure password hashing (Argon2 preferred) + identity lockout parameters; contribute config defaults. | PLG3, CORE3 | Hash verified via unit test + red team review. | |  | ||||||
| | 2 | SEC2 | Specify audit log schema/content (principal, client, scopes, IP) and ensure Authority Core implementation meets requirements. | CORE5–CORE7 | Review sample logs; ensure PII handled safely. | |  | ||||||
| | 3 | SEC3 | Define lockout & rate limit policies (per user/IP) and validate implementation in Authority Core. | CORE8 | Test harness proves lockouts triggered appropriately. | |  | ||||||
| | 4 | SEC4 | Design offline revocation list format + signing procedure; review implementation with Core/DevOps. | CORE9, OPS3 | Provide verification script for downstream systems. | |  | ||||||
| | 5 | SEC5 | Conduct threat model / security review (STRIDE) covering plugins, token flows, admin endpoints; produce mitigation backlog if needed. | CORE1–CORE10 | Document stored in `/docs/security`. | |  | ||||||
| | 6 | Oversight | Perform security review of CLI/Feedser integration changes (token handling, bypass masks). | FSR2, CLI2 | Approve PRs or request hardening changes. | |  | ||||||
|  |  | ||||||
| ## Implementation Notes |  | ||||||
| - Require secrets (client, bootstrap API keys) to meet minimum entropy; document rotation expectations.   |  | ||||||
| - Ensure bypass mask usage is fully logged + alertable.   |  | ||||||
| - Recommend default TLS cipher suites for Authority deployments.   |  | ||||||
| - Validate plugin capability metadata doesn’t expose insecure combinations (e.g., plugin without password support cannot be selected for password grant).   |  | ||||||
| - Develop checklist for production readiness (penetration test, log review, key rotation rehearsal). |  | ||||||
|  |  | ||||||
| ## Deliverables |  | ||||||
| - Security configuration recommendations (encoded in options + documentation).   |  | ||||||
| - Approved audit log schema & sample records.   |  | ||||||
| - Threat model document + mitigation backlog (if gaps discovered).   |  | ||||||
| - Sign-off memo to enable production rollout. |  | ||||||
|  |  | ||||||
| ## Coordination |  | ||||||
| - Work closely with Authority Core and Plugin teams during implementation; request changes early.   |  | ||||||
| - Pair with DevOps on key rotation / secret storage solutions.   |  | ||||||
| - Review Docs to ensure operator guidance includes security-critical steps.   |  | ||||||
| - Attend weekly Auth Guild sync to surface risks/blockers. |  | ||||||
| @@ -1,120 +0,0 @@ | |||||||
| # StellaOps.Authority — Implementation Backlog |  | ||||||
|  |  | ||||||
| > Status owner: Platform Authentication Guild   |  | ||||||
| > Source inspiration: `inspiration/Ablera.Serdica.*` (do **not** copy-paste; align with StellaOps coding standards) |  | ||||||
|  |  | ||||||
| ## 0. Foundations |  | ||||||
|  |  | ||||||
| | ID | Task | Owner | Notes / Acceptance | |  | ||||||
| |----|------|-------|---------------------| |  | ||||||
| | FND1 | Create solution scaffold under `src/StellaOps.Authority` (`StellaOps.Authority.sln` mirroring existing structure). | DevEx | **DONE** – Authority host + auth libraries + plugin stub scaffolded with net10.0 preview defaults. | |  | ||||||
| | FND2 | Extend `global.json`/Directory props to include new projects (net10.0). | DevEx | **DONE** – Directory props/targets cover Authority plugins; root `StellaOps.sln` enables repo-wide `dotnet build` (Feedser compile issues remain pre-existing). | |  | ||||||
| | FND3 | Define `StellaOpsAuthorityOptions` in `StellaOps.Configuration` (issuer, lifetimes, plugin directories, bypass masks). | BE-Base | **DONE** – Options class + bootstrapper with validation and tests; binds from YAML/JSON/env. | |  | ||||||
| | FND4 | Provide sample config `etc/authority.yaml.sample` with sensible defaults for offline-first deployments. | DevEx/Docs | **DONE** – Authority template published with token defaults + plug-in toggles and referenced in README/Quickstart. | |  | ||||||
| | FND5 | Add OpenTelemetry resource/version constants for Authority (service.name, namespace). | DevEx/Observability | **DONE** – Authority telemetry constants & helpers published for reuse by host/plugins. | |  | ||||||
|  |  | ||||||
| ## 1. Core Authority Service |  | ||||||
|  |  | ||||||
| | ID | Task | Owner | Notes / Acceptance | |  | ||||||
| |----|------|-------|---------------------| |  | ||||||
| | CORE1 | Bootstrap ASP.NET minimal API host with `StellaOps.Configuration` and plugin loading (reuse Feedser plugin host). | BE-Base | **DONE (2025-10-09)** – Host loads Authority options, Serilog, plugin registry; `/health` and `/ready` return 200. | |  | ||||||
| | CORE2 | Integrate OpenIddict server: configure issuer, endpoints (`/authorize`, `/token`, `/jwks`, `/introspect`, `/revoke`), token lifetimes. | BE-Auth | **DONE (2025-10-09)** – OpenIddict server wired with required endpoints, lifetimes, sliding refresh tokens, dev-only HTTPS relaxation. | |  | ||||||
| | CORE3 | Implement Mongo-backed stores (`AuthorityUser`, `AuthorityClient`, `AuthorityScope`, `AuthorityToken`, `AuthorityLoginAttempt`). | BE-Auth Storage | **DONE (2025-10-09)** – Mongo storage project with indexed collections, repository layer, and bootstrap migration runner wired to host. | |  | ||||||
| | CORE4 | Add `IUserCredentialStore`, `IClaimsEnricher`, `IClientCredentialStore`, `IIdentityProviderPlugin` abstractions (plugin contracts). | BE-Auth | Live under `StellaOps.Authority.Plugins.Abstractions`. | |  | ||||||
| | CORE5 | Port/customize OpenIddict event handlers (password grant, client credentials, token validation) using plugin contracts. | BE-Auth | **DONE (2025-10-10)** – Password, client-credentials, and token-validation handlers now enforce plugin capabilities, persist issued tokens, and run revocation checks. | |  | ||||||
| | CORE5A | Author integration tests verifying token persistence + revocation (client creds & refresh) through `IAuthorityTokenStore`. | QA, BE-Auth | Ensure revoked tokens are denied via handler + store wiring; cover reference token retrieval when implemented. | |  | ||||||
| | CORE5B | Document token persistence behaviour (revocation, enrichment) for resource servers + bootstrap guide. | Docs, BE-Auth | Update `docs/11_AUTHORITY.md` and plugin dev guide with new claims + store expectations before GA. | |  | ||||||
| | CORE6 | Implement API key protected bootstrap endpoints (`POST /internal/clients`, `POST /internal/users`) for initial provisioning. | BE-Auth | **DONE (2025-10-10)** – `/internal` APIs gated by bootstrap API key create users/clients through plugin stores. | |  | ||||||
| | CORE7 | Wire structured logging + OTEL spans for `/token`, `/authorize`, plugin actions. | BE-Auth Observability | Follows StellaOps logging conventions. | |  | ||||||
| | CORE8 | Add rate limiting middleware on `/token` and `/authorize`. | BE-Auth | Configurable via options; tests ensure throttle triggered. | |  | ||||||
| | CORE9 | Implement revocation (refresh + access) and publish signed offline revocation list. | BE-Auth | CLI hook to export list for air-gapped sync. | |  | ||||||
| | CORE10 | Provide JWKS endpoint backed by rotating signing/encryption keys (pluggable certificate loader). | BE-Auth | Document rotation workflow. | |  | ||||||
|  |  | ||||||
| ## 2. Plugin System |  | ||||||
|  |  | ||||||
| | ID | Task | Owner | Notes / Acceptance | |  | ||||||
| |----|------|-------|---------------------| |  | ||||||
| | PLG1 | Build `StellaOps.Authority.Plugins.Abstractions` (contracts, result models, constants). | BE-Auth | Align naming with StellaOps; add XML docs. | |  | ||||||
| | PLG2 | Implement plugin discovery via existing plugin host (search `PluginBinaries` for `StellaOps.Authority.Plugin.*`). | BE-Base | Provide diagnostics when plugin load fails. | |  | ||||||
| | PLG3 | Develop `StellaOps.Authority.Plugin.Standard` (Mongo-based user store, password hashing, lockout policy). | BE-Auth Storage | Includes configurable password policy + seed admin user. | |  | ||||||
| | PLG4 | Add plugin capability metadata (supportsPassword, supportsMfa, supportsClientProvisioning). | BE-Auth | **DONE (2025-10-10)** – Descriptor validation + registry logging wired; Standard plugin forces password capability and warns on misconfiguration. | |  | ||||||
| | PLG5 | Define plugin configuration schema under `etc/authority.plugins/*.yaml`; load via `StellaOps.Configuration`. | DevEx/Docs | **DONE** – Loader helpers + sample manifests committed; schema validated during bootstrap. | |  | ||||||
| | PLG6 | Publish developer guide for writing Authority plugins mirroring Feedser docs. | DevEx/Docs | **READY FOR DOCS REVIEW (2025-10-10)** – `docs/dev/31_AUTHORITY_PLUGIN_DEVELOPER_GUIDE.md` finalised with capability guidance, ops alignment, testing checklist; awaiting copy-edit & diagram polish by Docs guild. | |  | ||||||
| | PLG7 | Future placeholder: outline backlog for LDAP plugin (`StellaOps.Authority.Plugin.Ldap`) with story-level TODOs. | BE-Auth | **RFC DRAFTED (2025-10-10)** – See `docs/rfcs/authority-plugin-ldap.md` for architecture, configuration schema, testing plan, and open questions awaiting guild review. | |  | ||||||
|  |  | ||||||
| ## 3. Shared Auth Libraries |  | ||||||
|  |  | ||||||
| | ID | Task | Owner | Notes / Acceptance | |  | ||||||
| |----|------|-------|---------------------| |  | ||||||
| | LIB1 | Create `StellaOps.Auth.Abstractions` (claims, scopes, ProblemResultFactory, PrincipalBuilder). | BE-Auth | **DONE (2025-10-10)** – Added claim/scope constants, deterministic principal builder, problem result helpers, and xUnit coverage for normalization paths. | |  | ||||||
| | LIB2 | Implement `StellaOps.Auth.ServerIntegration` DI extensions (JWT bearer, bypass masks, policy helpers). | BE-Auth | **DONE (2025-10-10)** – Delivered `AddStellaOpsResourceServerAuthentication`, scope policies, bypass evaluator, and integration tests. | |  | ||||||
| | LIB3 | Migrate CIDR-matching logic (`NetworkMaskMatcher`) with IPv4/6 support + tests. | BE-Auth | **DONE (2025-10-10)** – New matcher + `NetworkMask` parser with 100% branch coverage replacing legacy serdica helpers. | |  | ||||||
| | LIB4 | Add `StellaOps.Auth.Client` with discovery, JWKS caching, password/client credentials flows, token cache abstraction. | DevEx/CLI | **DONE (2025-10-10)** – Implemented typed client, discovery/JWKS caches, in-memory/file token caches, and CLI-focused unit tests. | |  | ||||||
| | LIB5 | Integrate Polly (configurable) and HttpClientFactory patterns in client library. | DevEx | Ensure retries/offline fallback configurable. | |  | ||||||
| | LIB6 | Publish NuGet packaging metadata (License, SourceLink) for new libraries. | DevEx | Align with repo packaging conventions. | |  | ||||||
|  |  | ||||||
| ## 4. Feedser Integration |  | ||||||
|  |  | ||||||
| | ID | Task | Owner | Notes / Acceptance | |  | ||||||
| |----|------|-------|---------------------| |  | ||||||
| | FSR1 | Extend `etc/feedser.yaml` with Authority section (issuer, client credentials, bypass masks). | DevEx/Docs | Document mandatory vs optional settings. | |  | ||||||
| | FSR2 | Update Feedser WebService startup to call `AddStellaOpsResourceServerAuthentication` and enforce scopes/roles on job endpoints. | BE-Base | **DONE (2025-10-10)** – Feedser conditionally wires the resource server auth helper, protects all `/jobs` routes, and documents `authority` config. | |  | ||||||
| | FSR3 | Add configuration-driven fallback for on-host cron (network mask bypass). | BE-Base | Must be auditable via logs. | |  | ||||||
| | FSR4 | Adjust Feedser CLI doc references to note new auth requirements. | Docs | Update quickstart & CLI reference. | |  | ||||||
| | FSR5 | Write end-to-end integration tests (Authority + Feedser) verifying token issuance and job trigger flow (use docker-compose). | QA | Runs in CI nightly. | |  | ||||||
|  |  | ||||||
| ## 5. CLI Integration |  | ||||||
|  |  | ||||||
| | ID | Task | Owner | Notes / Acceptance | |  | ||||||
| |----|------|-------|---------------------| |  | ||||||
| | CLI1 | Extend CLI config (`StellaOpsCliOptions`) with Authority fields (AuthorityUrl, ClientId, ClientSecret, Username, Password). | DevEx/CLI | Environment variable support. | |  | ||||||
| | CLI2 | Implement `stellaops-cli auth login/logout/status` commands using `StellaOps.Auth.Client`. | DevEx/CLI | Tokens stored via `ITokenCache`; support password + client creds. | |  | ||||||
| | CLI3 | Ensure all API calls attach bearer tokens; handle 401/403 with friendly output. | DevEx/CLI | Regression tests for unauthorized scenarios. | |  | ||||||
| | CLI4 | Update CLI docs & help text to reference authentication workflow. | Docs | Include example flows. | |  | ||||||
|  |  | ||||||
| ## 6. Deployment & Ops |  | ||||||
|  |  | ||||||
| | ID | Task | Owner | Notes / Acceptance | |  | ||||||
| |----|------|-------|---------------------| |  | ||||||
| | OPS1 | Provide distroless Dockerfile + compose example (Authority + Mongo + optional Redis). | DevOps | **DONE (scaffold)** – Dockerfile + compose sample published under `ops/authority/`; offline-friendly mounts + volumes ready for DevOps hardening. | |  | ||||||
| | OPS2 | Implement CI pipeline stages (build, unit tests, integration tests, publish artifacts). | DevOps | **DONE** – CI workflow now builds/tests Authority, publishes artifacts, and builds container image alongside Feedser. | |  | ||||||
| | OPS3 | Add automated key rotation job (CLI or script) and document manual procedure. | DevOps/BE-Auth | Integrate with JWKS endpoint. | |  | ||||||
| | OPS4 | Document backup/restore steps for Authority Mongo collections and key material. | Docs/DevOps | Cover offline site restore. | |  | ||||||
| | OPS5 | Define monitoring/alerting rules (token issuance failure rates, auth errors). | Observability | Provide Prometheus/OpenTelemetry guidance. | |  | ||||||
|  |  | ||||||
| ## 7. Security & Compliance |  | ||||||
|  |  | ||||||
| | ID | Task | Owner | Notes / Acceptance | |  | ||||||
| |----|------|-------|---------------------| |  | ||||||
| | SEC1 | Adopt ASP.NET Identity password hashing defaults (Argon2 if available). | BE-Auth | Verify with penetration test harness. | |  | ||||||
| | SEC2 | Implement audit log (structured) for token issuance, revocation, admin actions (including plugin events). | BE-Auth | Logs must include principal, scopes, client, IP. | |  | ||||||
| | SEC3 | Add configurable lockout/throttle rules (per user + per IP). | BE-Auth | Integration tests confirm lock after threshold. | |  | ||||||
| | SEC4 | Support offline revocation list generation/signing (for air-gapped exports). | BE-Auth/QA | CLI command + verification doc. | |  | ||||||
| | SEC5 | Conduct threat model review + update documentation with mitigations. | Security Guild | Include password grant hardening notes. | |  | ||||||
|  |  | ||||||
| ## 8. Documentation & Enablement |  | ||||||
|  |  | ||||||
| | ID | Task | Owner | Notes / Acceptance | |  | ||||||
| |----|------|-------|---------------------| |  | ||||||
| | DOC1 | Author `docs/11_AUTHORITY.md` covering architecture, configuration, plugin model, operational playbooks. | Docs | Reference sample configs and CLI flows. | |  | ||||||
| | DOC2 | Produce API reference snippet (OpenAPI fragment) for `/token`, `/jwks`, `/introspect`, `/revoke`. | Docs/BE-Auth | Link in docs & README. | |  | ||||||
| | DOC3 | Write migration guide from anonymous Feedser to secured Feedser (staged rollout). | Docs/BE-Auth | Address bootstrap credentials and cut-over steps. | |  | ||||||
| | DOC4 | Create plugin developer how-to referencing new abstractions. | Docs/DevEx | Include example plugin skeleton. | |  | ||||||
| | DOC5 | Update repository README quickstart to point to Authority docs once live. | Docs | After Authority MVP lands. | |  | ||||||
|  |  | ||||||
| ## 9. Backlog / Future Enhancements |  | ||||||
|  |  | ||||||
| | ID | Idea | Notes | |  | ||||||
| |----|------|-------| |  | ||||||
| | FUT1 | Multi-factor authentication plugin capability (TOTP / WebAuthn) via plugin metadata. | Requires UX + plugin changes. | |  | ||||||
| | FUT2 | Admin UI (React/Angular) for managing users/clients. | Defer until API stabilizes. | |  | ||||||
| | FUT3 | Federation with Microsoft Entra ID using OIDC upstream (Authority acts as broker). | Align with future integration strategy. | |  | ||||||
| | FUT4 | Device authorization flow support for offline agents. | Dependent on client library maturity. | |  | ||||||
| | FUT5 | Plugin marketplace packaging guidelines (versioning, signing). | Coordinate with product team. | |  | ||||||
|  |  | ||||||
| --- |  | ||||||
|  |  | ||||||
| **Coordination Notes** |  | ||||||
| - Dedicated triage meetings weekly (Auth Guild) to review progress and unblock module owners. |  | ||||||
| - Plugin + Authority changes must coordinate with QA for end-to-end scenarios (Authority ↔ Feedser ↔ CLI). |  | ||||||
| - Security reviews required before enabling Authority in production environments. |  | ||||||
							
								
								
									
										17
									
								
								TASKS.md
									
									
									
									
									
								
							
							
						
						
									
										17
									
								
								TASKS.md
									
									
									
									
									
								
							| @@ -1,17 +0,0 @@ | |||||||
| # TASKS |  | ||||||
| | Task | Owner(s) | Depends on | Notes | |  | ||||||
| |---|---|---|---| |  | ||||||
| |Merge identity graph & alias store|BE-Merge|Models, Storage.Mongo|**DONE** – alias store/resolver, component builder, reconcile job, persistence + diagnostics endpoint landed.| |  | ||||||
| |OSV alias consolidation & per-ecosystem snapshots|BE-Conn-OSV, QA|Merge, Testing|DONE – alias graph handles GHSA/CVE records and deterministic snapshots exist across ecosystems.| |  | ||||||
| |Oracle PSIRT pipeline completion|BE-Conn-Oracle|Source.Common, Core|**DONE** – Oracle mapper now emits CVE aliases, vendor affected packages, patch references, and resume/backfill flow is covered by integration tests.| |  | ||||||
| |VMware connector observability & resume coverage|BE-Conn-VMware, QA|Source.Common, Storage.Mongo|**DONE** – VMware diagnostics emit fetch/parse/map metrics, fetch dedupe uses hash cache, and integration test covers snapshot plus resume path.| |  | ||||||
| |Model provenance & range backlog|BE-Merge|Models|**DOING** – VMware/Oracle/Chromium, NVD, Debian, SUSE, Ubuntu, Adobe, ICS Kaspersky, CERT-In, CERT-FR, JVN, and KEV now emit RangePrimitives (KEV adds due-date/vendor extensions with deterministic snapshots). Remaining connectors (`Acsc`, `Cccs`, `CertBund`, `CertCc`, `Cve`, `Ghsa`, `Ics.Cisa`, `Kisa`, `Ru.Bdu`, `Ru.Nkcki`, `Vndr.Apple`, `Vndr.Cisco`, `Vndr.Msrc`) still need structured coverage.| |  | ||||||
| |Trivy DB exporter delta strategy|BE-Export|Exporters|**DONE** – planner promotes chained deltas back to full exports, OCI writer reuses base blobs, regression tests cover the delta→delta→full sequence, and a full-stack layer-reuse smoke test + operator docs landed (2025-10-10).| |  | ||||||
| |Red Hat fixture validation sweep|QA|Source.Distro.RedHat|**DOING** – finalize RHSA fixture regeneration once connector regression fixes land.| |  | ||||||
| |JVN VULDEF schema update|BE-Conn-JVN, QA|Source.Jvn|**DONE** – schema patched (vendor/product attrs, impact entries, err codes), parser tightened, fixtures/tests refreshed.| |  | ||||||
| |Build/test sweeps|QA|All modules|**DONE** – wired Authority plugin abstractions into the build, updated CLI export tests for the new overrides, and full `dotnet test` now succeeds (perf suite within budget).| |  | ||||||
| |Authority plugin PLG1–PLG3|BE-Auth Plugin|Authority DevEx|**DONE** – abstractions/tests shipped, plugin loader integrated, and Mongo-backed Standard plugin stub operational with bootstrap seeding.| |  | ||||||
| |Authority plugin PLG4–PLG6|BE-Auth Plugin, DevEx/Docs|Authority plugin PLG1–PLG3|**READY FOR DOCS REVIEW (2025-10-10)** – Capability metadata validated, configuration guardrails shipped, developer guide finalised; waiting on Docs polish + diagram export.| |  | ||||||
| |Authority plugin PLG7 RFC|BE-Auth Plugin|PLG4|**DRAFTED (2025-10-10)** – `docs/rfcs/authority-plugin-ldap.md` captured LDAP plugin architecture, configuration schema, and implementation plan; needs Auth/Security guild review.| |  | ||||||
| |Feedser modularity test sweep|BE-Conn/QA|Feedser build|**DONE (2025-10-10)** – AngleSharp upgrade applied, helper assemblies copy-local, Kaspersky fixtures updated; full `dotnet test src/StellaOps.Feedser.sln` now passes locally.| |  | ||||||
| |OSV vs GHSA parity checks|QA, BE-Merge|Merge|**DONE** – parity inspector/diagnostics wired into OSV connector regression sweep; fixtures validated via `OsvGhsaParityRegressionTests` (see docs/19_TEST_SUITE_OVERVIEW.md) and metrics emitted through `OsvGhsaParityDiagnostics`.| |  | ||||||
							
								
								
									
										36
									
								
								TODOS.md
									
									
									
									
									
								
							
							
						
						
									
										36
									
								
								TODOS.md
									
									
									
									
									
								
							| @@ -1,36 +0,0 @@ | |||||||
| # Pending Task Backlog |  | ||||||
|  |  | ||||||
| > Last updated: 2025-10-09 (UTC) |  | ||||||
|  |  | ||||||
| ## Common |  | ||||||
|  |  | ||||||
| - **Build/test sweeps (QA – DONE)**   |  | ||||||
|   Full `dotnet test` is green again after wiring the Authority plugin abstractions into `StellaOps.Configuration` and updating CLI export tests for the new publish/include overrides. Keep running the sweep weekly and capture timings so we catch regressions early. |  | ||||||
|  |  | ||||||
| - **OSV vs GHSA parity checks (QA & BE-Merge – TODO)**   |  | ||||||
|   Design and implement a diff detector comparing OSV advisories against GHSA records. The deliverable should flag mismatched aliases, missing affected ranges, or divergent severities, surface actionable telemetry/alerts, and include regression tests with canned OSV+GHSA fixtures. |  | ||||||
|  |  | ||||||
| ## Prerequisites |  | ||||||
|  |  | ||||||
| - **Range primitives for SemVer/EVR/NEVRA metadata (BE-Merge – DOING)**   |  | ||||||
|   The core model supports range primitives, but several connectors still emit raw strings. Current gaps (snapshot 2025‑10‑09, post-Kaspersky/CERT-In/CERT-FR/JVN updates): `Acsc`, `Cccs`, `CertBund`, `CertCc`, `Cve`, `Ghsa`, `Ics.Cisa`, `Kev`, `Kisa`, `Ru.Bdu`, `Ru.Nkcki`, `Vndr.Apple`, `Vndr.Cisco`, `Vndr.Msrc`. We need to extend those mappers to populate the structured envelopes (SemVer/EVR/NEVRA plus vendor extensions) and add fixture coverage so merge/export layers see consistent telemetry. (Delivered: ICS.Kaspersky, CERT-In, CERT-FR emit vendor primitives; JVN captures version/build metadata.) |  | ||||||
|  |  | ||||||
| - **Provenance envelope field masks (BE-Merge – DOING)**   |  | ||||||
|   Provenance needs richer categorisation (component category, severity bands, resume counters) and better dedupe metrics. Update the provenance model, extend diagnostics to emit the new tags, and refresh dashboards/tests to ensure determinism once additional metadata flows through. |  | ||||||
|  |  | ||||||
| ## Implementations |  | ||||||
|  |  | ||||||
| - **Model provenance & range backlog (BE-Merge – DOING)**   |  | ||||||
|   With Adobe/Ubuntu now emitting range primitives, focus on the remaining connectors (e.g., Apple, smaller vendor PSIRTs). Update their pipelines, regenerate goldens, and confirm `feedser.range.primitives` metrics reflect the added telemetry. The task closes when every high-priority source produces structured ranges with provenance. |  | ||||||
|  |  | ||||||
| - **Trivy DB exporter delta strategy (BE-Export – TODO)**   |  | ||||||
|   Finalise the delta-reset story in `ExportStateManager`: define when to invalidate baselines, how to reuse unchanged layers, and document operator workflows. Implement planner logic for layer reuse, update exporter tests, and exercise a delta→full→delta sequence. |  | ||||||
|  |  | ||||||
| - **Red Hat fixture validation sweep (QA – DOING)**   |  | ||||||
|   Regenerate RHSA fixtures with the latest connector output and make sure the regenerated snapshots align once the outstanding connector tweaks land. Pending prerequisites: land the mapper reference-normalisation patch (local branch `redhat/ref-dedupe`) and the range provenance backfill (`RangePrimitives.GetCoverageTag`). Once those land, run `UPDATE_RHSA_FIXTURES=1 dotnet test src/StellaOps.Feedser.Source.Distro.RedHat.Tests/StellaOps.Feedser.Source.Distro.RedHat.Tests.csproj`, review the refreshed `Fixtures/rhsa-*.json`, and sync the task status to **DONE**. |  | ||||||
|  |  | ||||||
| - **Plan incremental/delta exports (BE-Export – DOING)**   |  | ||||||
|   `TrivyDbExportPlanner` now captures changed files but does not yet reuse existing OCI layers. Extend the planner to build per-file manifests, teach the writer to skip untouched layers, and add delta-cycle tests covering file removals, additions, and checksum changes. |  | ||||||
|  |  | ||||||
| - **Scan execution & result upload workflow (DevEx/CLI & Ops Integrator – DOING)**   |  | ||||||
|   `stella scan run` now emits a structured `scan-run-*.json` alongside artefacts. Remaining work: add resilient upload retries/backoff, cover success/retry/cancellation with integration tests, and expand docs with docker/dotnet/native runner examples plus metadata troubleshooting tips. |  | ||||||
| @@ -1,3 +0,0 @@ | |||||||
| # Web UI Follow-ups |  | ||||||
|  |  | ||||||
| - Trivy DB exporter settings panel: surface `publishFull` / `publishDelta` and `includeFull` / `includeDelta` toggles, saving overrides via future `/exporters/trivy-db/settings` API. Include “run export now” button that reuses those overrides when triggering `export:trivy-db`. |  | ||||||
| @@ -208,6 +208,17 @@ Configuration follows the same precedence chain everywhere: | |||||||
| 3. `appsettings.yaml` → `appsettings.local.yaml`   | 3. `appsettings.yaml` → `appsettings.local.yaml`   | ||||||
| 4. Defaults (`ApiKey = ""`, `BackendUrl = ""`, cache folders under the current working directory) | 4. Defaults (`ApiKey = ""`, `BackendUrl = ""`, cache folders under the current working directory) | ||||||
|  |  | ||||||
|  | **Authority auth client resilience settings** | ||||||
|  |  | ||||||
|  | | Setting | Environment variable | Default | Purpose | | ||||||
|  | |---------|----------------------|---------|---------| | ||||||
|  | | `StellaOps:Authority:Resilience:EnableRetries` | `STELLAOPS_AUTHORITY_ENABLE_RETRIES` | `true` | Toggle Polly wait-and-retry handlers for discovery/token calls | | ||||||
|  | | `StellaOps:Authority:Resilience:RetryDelays` | `STELLAOPS_AUTHORITY_RETRY_DELAYS` | `1s,2s,5s` | Comma/space-separated backoff sequence (HH:MM:SS) | | ||||||
|  | | `StellaOps:Authority:Resilience:AllowOfflineCacheFallback` | `STELLAOPS_AUTHORITY_ALLOW_OFFLINE_CACHE_FALLBACK` | `true` | Reuse cached discovery/JWKS metadata when Authority is temporarily unreachable | | ||||||
|  | | `StellaOps:Authority:Resilience:OfflineCacheTolerance` | `STELLAOPS_AUTHORITY_OFFLINE_CACHE_TOLERANCE` | `00:10:00` | Additional tolerance window added to the discovery/JWKS cache lifetime | | ||||||
|  |  | ||||||
|  | See `docs/dev/32_AUTH_CLIENT_GUIDE.md` for recommended profiles (online vs. air-gapped) and testing guidance. | ||||||
|  |  | ||||||
| | Command | Purpose | Key Flags / Arguments | Notes | | | Command | Purpose | Key Flags / Arguments | Notes | | ||||||
| |---------|---------|-----------------------|-------| | |---------|---------|-----------------------|-------| | ||||||
| | `stellaops-cli scanner download` | Fetch and install scanner container | `--channel <stable\|beta\|nightly>` (default `stable`)<br>`--output <path>`<br>`--overwrite`<br>`--no-install` | Saves artefact under `ScannerCacheDirectory`, verifies digest/signature, and executes `docker load` unless `--no-install` is supplied. | | | `stellaops-cli scanner download` | Fetch and install scanner container | `--channel <stable\|beta\|nightly>` (default `stable`)<br>`--output <path>`<br>`--overwrite`<br>`--no-install` | Saves artefact under `ScannerCacheDirectory`, verifies digest/signature, and executes `docker load` unless `--no-install` is supplied. | | ||||||
| @@ -216,7 +227,7 @@ Configuration follows the same precedence chain everywhere: | |||||||
| | `stellaops-cli db fetch` | Trigger connector jobs | `--source <id>` (e.g. `redhat`, `osv`)<br>`--stage <fetch\|parse\|map>` (default `fetch`)<br>`--mode <resume|init|cursor>` | Translates to `POST /jobs/source:{source}:{stage}` with `trigger=cli` | | | `stellaops-cli db fetch` | Trigger connector jobs | `--source <id>` (e.g. `redhat`, `osv`)<br>`--stage <fetch\|parse\|map>` (default `fetch`)<br>`--mode <resume|init|cursor>` | Translates to `POST /jobs/source:{source}:{stage}` with `trigger=cli` | | ||||||
| | `stellaops-cli db merge` | Run canonical merge reconcile | — | Calls `POST /jobs/merge:reconcile`; exit code `0` on acceptance, `1` on failures/conflicts | | | `stellaops-cli db merge` | Run canonical merge reconcile | — | Calls `POST /jobs/merge:reconcile`; exit code `0` on acceptance, `1` on failures/conflicts | | ||||||
| | `stellaops-cli db export` | Kick JSON / Trivy exports | `--format <json\|trivy-db>` (default `json`)<br>`--delta`<br>`--publish-full/--publish-delta`<br>`--bundle-full/--bundle-delta` | Sets `{ delta = true }` parameter when requested and can override ORAS/bundle toggles per run | | | `stellaops-cli db export` | Kick JSON / Trivy exports | `--format <json\|trivy-db>` (default `json`)<br>`--delta`<br>`--publish-full/--publish-delta`<br>`--bundle-full/--bundle-delta` | Sets `{ delta = true }` parameter when requested and can override ORAS/bundle toggles per run | | ||||||
| | `stellaops-cli auth <login\|logout\|status>` | Manage cached tokens for StellaOps Authority | `auth login --force` (ignore cache)<br>`auth status` | Uses `StellaOps.Auth.Client` under the hood; honours `StellaOps:Authority:*` configuration | | | `stellaops-cli auth <login\|logout\|status\|whoami>` | Manage cached tokens for StellaOps Authority | `auth login --force` (ignore cache)<br>`auth status`<br>`auth whoami` | Uses `StellaOps.Auth.Client`; honours `StellaOps:Authority:*` configuration, stores tokens under `~/.stellaops/tokens` by default, and `whoami` prints subject/scope/expiry | | ||||||
|  |  | ||||||
| When running on an interactive terminal without explicit override flags, the CLI uses Spectre.Console prompts to let you choose per-run ORAS/offline bundle behaviour. | When running on an interactive terminal without explicit override flags, the CLI uses Spectre.Console prompts to let you choose per-run ORAS/offline bundle behaviour. | ||||||
| | `stellaops-cli config show` | Display resolved configuration | — | Masks secret values; helpful for air‑gapped installs | | | `stellaops-cli config show` | Display resolved configuration | — | Masks secret values; helpful for air‑gapped installs | | ||||||
| @@ -275,8 +286,20 @@ feedser: | |||||||
| **Authentication** | **Authentication** | ||||||
|  |  | ||||||
| - API key is sent as `Authorization: Bearer <token>` automatically when configured.   | - API key is sent as `Authorization: Bearer <token>` automatically when configured.   | ||||||
| - Anonymous operation (empty key) is permitted for offline use cases but backend calls will fail with 401 unless the Feedser instance allows guest access. | - Anonymous operation is permitted only when Feedser runs with | ||||||
| - When `StellaOps:Authority:Url` is set the CLI initialises the StellaOps auth client. Use `stellaops-cli auth login` to obtain a token (password grant when `Username`/`Password` are set, otherwise client credentials). Tokens are cached under `~/.stellaops/tokens` by default; `auth status` shows expiry and `auth logout` removes the cached entry. |   `authority.allowAnonymousFallback: true`. This flag is temporary—plan to disable | ||||||
|  |   it before **2025-12-31 UTC** so bearer tokens become mandatory. | ||||||
|  |  | ||||||
|  | Authority-backed auth workflow: | ||||||
|  | 1. Configure Authority settings via config or env vars (see sample below). Minimum fields: `Url`, `ClientId`, and either `ClientSecret` (client credentials) or `Username`/`Password` (password grant).   | ||||||
|  | 2. Run `stellaops-cli auth login` to acquire and cache a token. Use `--force` if you need to ignore an existing cache entry.   | ||||||
|  | 3. Execute CLI commands as normal—the backend client injects the cached bearer token automatically and retries on transient 401/403 responses with operator guidance.   | ||||||
|  | 4. Inspect the cache with `stellaops-cli auth status` (shows expiry, scope, mode) or clear it via `stellaops-cli auth logout`.   | ||||||
|  | 5. Run `stellaops-cli auth whoami` to dump token subject, audience, issuer, scopes, and remaining lifetime (verbose mode prints additional claims). | ||||||
|  | 6. Expect Feedser to emit audit logs for each `/jobs*` request showing `subject`, | ||||||
|  |    `clientId`, `scopes`, `status`, and whether network bypass rules were applied. | ||||||
|  |  | ||||||
|  | Tokens live in `~/.stellaops/tokens` unless `StellaOps:Authority:TokenCacheDirectory` overrides it. Cached tokens are reused offline until they expire; the CLI surfaces clear errors if refresh fails. | ||||||
|  |  | ||||||
| **Configuration file template** | **Configuration file template** | ||||||
|  |  | ||||||
|   | |||||||
| @@ -55,8 +55,9 @@ runtime wiring, CLI usage) and leaves connector/internal customization for later | |||||||
|    - `GET /ready` – performs a MongoDB `ping` |    - `GET /ready` – performs a MongoDB `ping` | ||||||
|    - `GET /jobs` + `POST /jobs/{kind}` – inspect and trigger connector/export jobs |    - `GET /jobs` + `POST /jobs/{kind}` – inspect and trigger connector/export jobs | ||||||
|  |  | ||||||
|    > **Security note** – authentication is not wired yet; guard the service with |   > **Security note** – authentication now ships via StellaOps Authority. Keep | ||||||
|    > network controls or a reverse proxy until auth middleware ships. |   > `authority.allowAnonymousFallback: true` only during the staged rollout and | ||||||
|  |   > disable it before **2025-12-31 UTC** so tokens become mandatory. | ||||||
|  |  | ||||||
| ### Authority companion configuration (preview) | ### Authority companion configuration (preview) | ||||||
|  |  | ||||||
| @@ -90,18 +91,39 @@ defaults live in `src/StellaOps.Cli/appsettings.json` and expect overrides at ru | |||||||
| | Setting | Environment variable | Default | Purpose | | | Setting | Environment variable | Default | Purpose | | ||||||
| | ------- | -------------------- | ------- | ------- | | | ------- | -------------------- | ------- | ------- | | ||||||
| | `BackendUrl` | `STELLAOPS_BACKEND_URL` | _empty_ | Base URL of the Feedser web service | | | `BackendUrl` | `STELLAOPS_BACKEND_URL` | _empty_ | Base URL of the Feedser web service | | ||||||
| | `ApiKey` | `API_KEY` | _empty_ | Reserved for future auth; keep empty today | | | `ApiKey` | `API_KEY` | _empty_ | Reserved for legacy key auth; leave empty when using Authority | | ||||||
| | `ScannerCacheDirectory` | `STELLAOPS_SCANNER_CACHE_DIRECTORY` | `scanners` | Local cache folder | | | `ScannerCacheDirectory` | `STELLAOPS_SCANNER_CACHE_DIRECTORY` | `scanners` | Local cache folder | | ||||||
| | `ResultsDirectory` | `STELLAOPS_RESULTS_DIRECTORY` | `results` | Where scan outputs are written | | | `ResultsDirectory` | `STELLAOPS_RESULTS_DIRECTORY` | `results` | Where scan outputs are written | | ||||||
|  | | `Authority.Url` | `STELLAOPS_AUTHORITY_URL` | _empty_ | StellaOps Authority issuer/token endpoint | | ||||||
|  | | `Authority.ClientId` | `STELLAOPS_AUTHORITY_CLIENT_ID` | _empty_ | Client identifier for the CLI | | ||||||
|  | | `Authority.ClientSecret` | `STELLAOPS_AUTHORITY_CLIENT_SECRET` | _empty_ | Client secret (omit when using username/password grant) | | ||||||
|  | | `Authority.Username` | `STELLAOPS_AUTHORITY_USERNAME` | _empty_ | Username for password grant flows | | ||||||
|  | | `Authority.Password` | `STELLAOPS_AUTHORITY_PASSWORD` | _empty_ | Password for password grant flows | | ||||||
|  | | `Authority.Scope` | `STELLAOPS_AUTHORITY_SCOPE` | `feedser.jobs.trigger` | OAuth scope requested for backend operations | | ||||||
|  | | `Authority.TokenCacheDirectory` | `STELLAOPS_AUTHORITY_TOKEN_CACHE_DIR` | `~/.stellaops/tokens` | Directory that persists cached tokens | | ||||||
|  | | `Authority.Resilience.EnableRetries` | `STELLAOPS_AUTHORITY_ENABLE_RETRIES` | `true` | Toggle Polly retry handler for Authority HTTP calls | | ||||||
|  | | `Authority.Resilience.RetryDelays` | `STELLAOPS_AUTHORITY_RETRY_DELAYS` | `1s,2s,5s` | Comma- or space-separated backoff delays (hh:mm:ss) | | ||||||
|  | | `Authority.Resilience.AllowOfflineCacheFallback` | `STELLAOPS_AUTHORITY_ALLOW_OFFLINE_CACHE_FALLBACK` | `true` | Allow CLI to reuse cached discovery/JWKS metadata when Authority is offline | | ||||||
|  | | `Authority.Resilience.OfflineCacheTolerance` | `STELLAOPS_AUTHORITY_OFFLINE_CACHE_TOLERANCE` | `00:10:00` | Additional tolerance window applied to cached metadata | | ||||||
|  |  | ||||||
| Example bootstrap: | Example bootstrap: | ||||||
|  |  | ||||||
| ```bash | ```bash | ||||||
| export STELLAOPS_BACKEND_URL="http://localhost:5000" | export STELLAOPS_BACKEND_URL="http://localhost:5000" | ||||||
| export STELLAOPS_RESULTS_DIRECTORY="$HOME/.stellaops/results" | export STELLAOPS_RESULTS_DIRECTORY="$HOME/.stellaops/results" | ||||||
|  | export STELLAOPS_AUTHORITY_URL="https://authority.local" | ||||||
|  | export STELLAOPS_AUTHORITY_CLIENT_ID="feedser-cli" | ||||||
|  | export STELLAOPS_AUTHORITY_CLIENT_SECRET="s3cr3t" | ||||||
| dotnet run --project src/StellaOps.Cli -- db merge | dotnet run --project src/StellaOps.Cli -- db merge | ||||||
|  |  | ||||||
|  | # Acquire a bearer token and confirm cache state | ||||||
|  | dotnet run --project src/StellaOps.Cli -- auth login | ||||||
|  | dotnet run --project src/StellaOps.Cli -- auth status | ||||||
|  | dotnet run --project src/StellaOps.Cli -- auth whoami | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
|  | Refer to `docs/dev/32_AUTH_CLIENT_GUIDE.md` for deeper guidance on tuning retry/offline settings and rollout checklists. | ||||||
|  |  | ||||||
| To persist configuration, you can create `stellaops-cli.yaml` next to the binary or | To persist configuration, you can create `stellaops-cli.yaml` next to the binary or | ||||||
| rely on environment variables for ephemeral runners. | rely on environment variables for ephemeral runners. | ||||||
|  |  | ||||||
| @@ -212,21 +234,56 @@ a problem document. | |||||||
|  |  | ||||||
| ## 5 · Next Steps | ## 5 · Next Steps | ||||||
|  |  | ||||||
| - Introduce authentication/authorization in the web service before exposing it on | - Enable authority-backed authentication in non-production first. Set | ||||||
|   shared networks. |   `authority.enabled: true` while keeping `authority.allowAnonymousFallback: true` | ||||||
|  |   to observe logs, then flip it to `false` before 2025-12-31 UTC to enforce tokens. | ||||||
| - Automate the workflow above via CI/CD (compose stack or Kubernetes CronJobs). | - Automate the workflow above via CI/CD (compose stack or Kubernetes CronJobs). | ||||||
| - Pair with the Feedser connector teams when enabling additional sources so their | - Pair with the Feedser connector teams when enabling additional sources so their | ||||||
|   module-specific requirements are pulled in safely. |   module-specific requirements are pulled in safely. | ||||||
|  |  | ||||||
| --- | --- | ||||||
|  |  | ||||||
| ## 6 · Microsoft Authentication Integration (Planned) | ## 6 · Authority Integration | ||||||
|  |  | ||||||
|  | - Feedser now authenticates callers through StellaOps Authority using OAuth 2.0 | ||||||
|  |   resource server flows. Populate the `authority` block in `feedser.yaml`: | ||||||
|  |  | ||||||
|  |   ```yaml | ||||||
|  |   authority: | ||||||
|  |     enabled: true | ||||||
|  |     allowAnonymousFallback: false         # keep true only during the staged rollout window | ||||||
|  |     issuer: "https://authority.example.org" | ||||||
|  |     audiences: | ||||||
|  |       - "api://feedser" | ||||||
|  |     requiredScopes: | ||||||
|  |       - "feedser.jobs.trigger" | ||||||
|  |     clientId: "feedser-jobs" | ||||||
|  |     clientSecretFile: "../secrets/feedser-jobs.secret" | ||||||
|  |     clientScopes: | ||||||
|  |       - "feedser.jobs.trigger" | ||||||
|  |     bypassNetworks: | ||||||
|  |       - "127.0.0.1/32" | ||||||
|  |       - "::1/128" | ||||||
|  |   ``` | ||||||
|  |  | ||||||
|  | - Store the client secret outside of source control. Either provide it via | ||||||
|  |   `authority.clientSecret` (environment variable `FEEDSER_AUTHORITY__CLIENTSECRET`) | ||||||
|  |   or point `authority.clientSecretFile` to a file mounted at runtime. | ||||||
|  | - Cron jobs running on the same host can keep using the API thanks to the loopback | ||||||
|  |   bypass mask. Add additional CIDR ranges as needed; every bypass is logged. | ||||||
|  | - Export the same configuration to Kubernetes or systemd by setting environment | ||||||
|  |   variables such as: | ||||||
|  |  | ||||||
|  |   ```bash | ||||||
|  |   export FEEDSER_AUTHORITY__ENABLED=true | ||||||
|  |   export FEEDSER_AUTHORITY__ALLOWANONYMOUSFALLBACK=false | ||||||
|  |   export FEEDSER_AUTHORITY__ISSUER="https://authority.example.org" | ||||||
|  |   export FEEDSER_AUTHORITY__CLIENTID="feedser-jobs" | ||||||
|  |   export FEEDSER_AUTHORITY__CLIENTSECRETFILE="/var/run/secrets/feedser/authority-client" | ||||||
|  |   ``` | ||||||
|  |  | ||||||
| - The Feedser web service will integrate with the Microsoft identity stack (Entra ID) |  | ||||||
|   using OAuth 2.0. Expect additional configuration keys for authority URLs, client |  | ||||||
|   IDs/secrets, and audience scopes once the implementation lands. |  | ||||||
| - CLI commands already pass `Authorization` headers when credentials are supplied. | - CLI commands already pass `Authorization` headers when credentials are supplied. | ||||||
|   When auth is enabled, point `stellaops-cli` at the token issuer (client credentials |   Configure the CLI with matching Authority settings (`docs/09_API_CLI_REFERENCE.md`) | ||||||
|   flow) or run it behind a proxy that injects bearer tokens. |   so that automation can obtain tokens with the same client credentials. Feedser | ||||||
| - Keep network-facing deployments behind reverse proxies or firewalls until the |   logs every job request with the client ID, subject (if present), scopes, and | ||||||
|   authentication middleware ships and is fully validated. |   a `bypass` flag so operators can audit cron traffic. | ||||||
|   | |||||||
| @@ -140,10 +140,25 @@ cosign verify ghcr.io/stellaops/backend@sha256:<DIGEST> \ | |||||||
|  |  | ||||||
| | Control      | Implementation                                                    | | | Control      | Implementation                                                    | | ||||||
| | ------------ | ----------------------------------------------------------------- | | | ------------ | ----------------------------------------------------------------- | | ||||||
| | Log format   | Serilog JSON; ship via Fluent‑Bit to ELK or Loki                  | | | Log format   | Serilog JSON; ship via Fluent‑Bit to ELK or Loki                  | | ||||||
| | Metrics      | Prometheus /metrics endpoint; default Grafana dashboard in infra/ | | | Metrics      | Prometheus /metrics endpoint; default Grafana dashboard in infra/ | | ||||||
| | Audit events | Redis stream audit; export daily to SIEM                          | | | Audit events | Redis stream audit; export daily to SIEM                          | | ||||||
| | Alert rules  | Feed age  ≥ 48 h, P95 wall‑time > 5 s, Redis used memory > 75 %   | | | Alert rules  | Feed age  ≥ 48 h, P95 wall‑time > 5 s, Redis used memory > 75 %   | | ||||||
|  |  | ||||||
|  | ###  7.1 Feedser authorization audits | ||||||
|  |  | ||||||
|  | - Enable the Authority integration for Feedser (`authority.enabled=true`). Keep | ||||||
|  |   `authority.allowAnonymousFallback` set to `true` only during migration and plan | ||||||
|  |   to disable it before **2025-12-31 UTC** so the `/jobs*` surface always demands | ||||||
|  |   a bearer token. | ||||||
|  | - Store the Authority client secret using Docker/Kubernetes secrets and point | ||||||
|  |   `authority.clientSecretFile` at the mounted path; the value is read at startup | ||||||
|  |   and never logged. | ||||||
|  | - Watch the `Feedser.Authorization.Audit` logger. Each entry contains the HTTP | ||||||
|  |   status, subject, client ID, scopes, remote IP, and a boolean `bypass` flag | ||||||
|  |   showing whether a network bypass CIDR allowed the request. Configure your SIEM | ||||||
|  |   to alert when unauthenticated requests (`status=401`) appear with | ||||||
|  |   `bypass=true`, or when unexpected scopes invoke job triggers. | ||||||
|  |  | ||||||
| ##  8 Update & patch strategy | ##  8 Update & patch strategy | ||||||
|  |  | ||||||
|   | |||||||
| @@ -65,20 +65,58 @@ $EDITOR .env | |||||||
| # 5. Launch databases (MongoDB + Redis) | # 5. Launch databases (MongoDB + Redis) | ||||||
| docker compose --env-file .env -f docker-compose.infrastructure.yml up -d | docker compose --env-file .env -f docker-compose.infrastructure.yml up -d | ||||||
|  |  | ||||||
| # 6. Launch Stella Ops (first run pulls ~50 MB merged vuln DB) | # 6. Launch Stella Ops (first run pulls ~50 MB merged vuln DB) | ||||||
| docker compose --env-file .env -f docker-compose.stella-ops.yml up -d | docker compose --env-file .env -f docker-compose.stella-ops.yml up -d | ||||||
| ```` | ```` | ||||||
|  |  | ||||||
| *Default login:* `admin / changeme` | *Default login:* `admin / changeme` | ||||||
| UI: [https://\<host\>:8443](https://<host>:8443) (self‑signed certificate) | UI: [https://\<host\>:8443](https://<host>:8443) (self‑signed certificate) | ||||||
|  |  | ||||||
| > **Pinning best‑practice** – in production environments replace | > **Pinning best‑practice** – in production environments replace | ||||||
| > `stella-ops:latest` with the immutable digest printed by | > `stella-ops:latest` with the immutable digest printed by | ||||||
| > `docker images --digests`. | > `docker images --digests`. | ||||||
|  |  | ||||||
| --- | ### 1.1 · Feedser authority configuration | ||||||
|  |  | ||||||
| ## 2 · Optional: request a free quota token | The Feedser container reads configuration from `etc/feedser.yaml` plus | ||||||
|  | `FEEDSER_` environment variables. To enable the new Authority integration: | ||||||
|  |  | ||||||
|  | 1. Add the following keys to `.env` (replace values for your environment): | ||||||
|  |  | ||||||
|  |    ```bash | ||||||
|  |    FEEDSER_AUTHORITY__ENABLED=true | ||||||
|  |    FEEDSER_AUTHORITY__ALLOWANONYMOUSFALLBACK=true   # temporary rollout only | ||||||
|  |    FEEDSER_AUTHORITY__ISSUER="https://authority.internal" | ||||||
|  |    FEEDSER_AUTHORITY__AUDIENCES__0="api://feedser" | ||||||
|  |    FEEDSER_AUTHORITY__REQUIREDSCOPES__0="feedser.jobs.trigger" | ||||||
|  |    FEEDSER_AUTHORITY__CLIENTID="feedser-jobs" | ||||||
|  |    FEEDSER_AUTHORITY__CLIENTSECRETFILE="/run/secrets/feedser_authority_client" | ||||||
|  |    FEEDSER_AUTHORITY__BYPASSNETWORKS__0="127.0.0.1/32" | ||||||
|  |    FEEDSER_AUTHORITY__BYPASSNETWORKS__1="::1/128" | ||||||
|  |    ``` | ||||||
|  |  | ||||||
|  |    Store the client secret outside source control (Docker secrets, mounted file, | ||||||
|  |    or Kubernetes Secret). Feedser loads the secret during post-configuration, so | ||||||
|  |    the value never needs to appear in the YAML template. | ||||||
|  |  | ||||||
|  | 2. Redeploy Feedser: | ||||||
|  |  | ||||||
|  |    ```bash | ||||||
|  |    docker compose --env-file .env -f docker-compose.stella-ops.yml up -d feedser | ||||||
|  |    ``` | ||||||
|  |  | ||||||
|  | 3. Tail the logs: `docker compose logs -f feedser`. Successful `/jobs*` calls now | ||||||
|  |    emit `Feedser.Authorization.Audit` entries listing subject, client ID, scopes, | ||||||
|  |    remote IP, and whether the bypass CIDR allowed the call. 401 denials always log | ||||||
|  |    `bypassAllowed=false` so unauthenticated cron jobs are easy to catch. | ||||||
|  |  | ||||||
|  | > **Enforcement deadline** – keep `FEEDSER_AUTHORITY__ALLOWANONYMOUSFALLBACK=true` | ||||||
|  | > only while validating the rollout. Set it to `false` (and restart Feedser) | ||||||
|  | > before **2025-12-31 UTC** to require tokens in production. | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## 2 · Optional: request a free quota token | ||||||
|  |  | ||||||
| Anonymous installs allow **{{ quota\_anon }} scans per UTC day**. | Anonymous installs allow **{{ quota\_anon }} scans per UTC day**. | ||||||
| Email `token@stella-ops.org` to receive a signed JWT that raises the limit to | Email `token@stella-ops.org` to receive a signed JWT that raises the limit to | ||||||
|   | |||||||
							
								
								
									
										19
									
								
								docs/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								docs/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | |||||||
|  | # Docs & Enablement Guild | ||||||
|  |  | ||||||
|  | ## Mission | ||||||
|  | Produce and maintain offline-friendly documentation for StellaOps modules, covering architecture, configuration, operator workflows, and developer onboarding. | ||||||
|  |  | ||||||
|  | ## Scope Highlights | ||||||
|  | - Authority docs (`docs/dev/31_AUTHORITY_PLUGIN_DEVELOPER_GUIDE.md`, upcoming `docs/11_AUTHORITY.md`). | ||||||
|  | - Feedser quickstarts, CLI guides, Offline Kit manuals. | ||||||
|  | - Release notes and migration playbooks. | ||||||
|  |  | ||||||
|  | ## Operating Principles | ||||||
|  | - Keep guides deterministic and in sync with shipped configuration samples. | ||||||
|  | - Prefer tables/checklists for operator steps; flag security-sensitive actions. | ||||||
|  | - Update `docs/TASKS.md` whenever work items change status (TODO/DOING/REVIEW/DONE/BLOCKED). | ||||||
|  |  | ||||||
|  | ## Coordination | ||||||
|  | - Authority Core & Plugin teams for auth-related changes. | ||||||
|  | - Security Guild for threat-model outputs and mitigations. | ||||||
|  | - DevEx for tooling diagrams and documentation pipeline. | ||||||
							
								
								
									
										10
									
								
								docs/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								docs/TASKS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | |||||||
|  | # Docs Guild Task Board (UTC 2025-10-10) | ||||||
|  |  | ||||||
|  | | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||||
|  | |----|--------|----------|------------|-------------|---------------| | ||||||
|  | | DOC4.AUTH-PDG | REVIEW | Docs Guild, Plugin Team | PLG6.DOC | Copy-edit `docs/dev/31_AUTHORITY_PLUGIN_DEVELOPER_GUIDE.md`, export lifecycle diagram, add LDAP RFC cross-link. | ✅ PR merged with polish; ✅ Diagram committed; ✅ Slack handoff posted. | | ||||||
|  | | DOC1.AUTH | TODO | Docs Guild, Authority Core | CORE5B.DOC | Draft `docs/11_AUTHORITY.md` covering architecture, configuration, bootstrap flows. | ✅ Architecture + config sections approved by Core; ✅ Samples reference latest options; ✅ Offline note added. | | ||||||
|  | | DOC3.Feedser-Authority | DOING (2025-10-10) | Docs Guild, DevEx | FSR4 | Polish operator/runbook sections (DOC3/DOC5) to document Feedser authority rollout, bypass logging, and enforcement checklist. | ✅ DOC3/DOC5 updated; ✅ enforcement deadline highlighted; ✅ Docs guild sign-off. | | ||||||
|  | | DOC5.Feedser-Runbook | TODO | Docs Guild | DOC3.Feedser-Authority | Produce dedicated Feedser authority audit runbook covering log fields, monitoring recommendations, and troubleshooting steps. | ✅ Runbook published; ✅ linked from DOC3/DOC5; ✅ alerting guidance included. | | ||||||
|  |  | ||||||
|  | > Update statuses (TODO/DOING/REVIEW/DONE/BLOCKED) as progress changes. Keep guides in sync with configuration samples under `etc/`. | ||||||
| @@ -38,6 +38,8 @@ Capability flags let the host reason about what your plug-in supports: | |||||||
|  |  | ||||||
| **Operational reminder:** the Authority host surfaces capability summaries during startup (see `AuthorityIdentityProviderRegistry` log lines). Use those logs during smoke tests to ensure manifests align with expectations. | **Operational reminder:** the Authority host surfaces capability summaries during startup (see `AuthorityIdentityProviderRegistry` log lines). Use those logs during smoke tests to ensure manifests align with expectations. | ||||||
|  |  | ||||||
|  | **Configuration path normalisation:** Manifest-relative paths (e.g., `tokenSigning.keyDirectory: "../keys"`) are resolved against the YAML file location and environment variables are expanded before validation. Plug-ins should expect to receive an absolute, canonical path when options are injected. | ||||||
|  |  | ||||||
| ## 4. Project Scaffold | ## 4. Project Scaffold | ||||||
| - Target **.NET 10 preview**, enable nullable, treat warnings as errors, and mark Authority plug-ins with `<IsAuthorityPlugin>true</IsAuthorityPlugin>`. | - Target **.NET 10 preview**, enable nullable, treat warnings as errors, and mark Authority plug-ins with `<IsAuthorityPlugin>true</IsAuthorityPlugin>`. | ||||||
| - Minimum references: | - Minimum references: | ||||||
|   | |||||||
							
								
								
									
										91
									
								
								docs/dev/32_AUTH_CLIENT_GUIDE.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								docs/dev/32_AUTH_CLIENT_GUIDE.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,91 @@ | |||||||
|  | # StellaOps Auth Client — Integration Guide | ||||||
|  |  | ||||||
|  | > **Status:** Drafted 2025-10-10 as part of LIB5. Consumer teams (Feedser, CLI, Agent) should review before wiring the new options into their configuration surfaces. | ||||||
|  |  | ||||||
|  | The `StellaOps.Auth.Client` library provides a resilient OpenID Connect client for services and tools that talk to **StellaOps Authority**. LIB5 introduced configurable HTTP retry/backoff policies and an offline-fallback window so downstream components stay deterministic even when Authority is briefly unavailable. | ||||||
|  |  | ||||||
|  | This guide explains how to consume the new settings, when to toggle them, and how to test your integration. | ||||||
|  |  | ||||||
|  | ## 1. Registering the client | ||||||
|  |  | ||||||
|  | ```csharp | ||||||
|  | services.AddStellaOpsAuthClient(options => | ||||||
|  | { | ||||||
|  |     options.Authority = configuration["StellaOps:Authority:Url"]!; | ||||||
|  |     options.ClientId = configuration["StellaOps:Authority:ClientId"]!; | ||||||
|  |     options.ClientSecret = configuration["StellaOps:Authority:ClientSecret"]; | ||||||
|  |     options.DefaultScopes.Add("feedser.jobs.trigger"); | ||||||
|  |  | ||||||
|  |     options.EnableRetries = true; | ||||||
|  |     options.RetryDelays.Clear(); | ||||||
|  |     options.RetryDelays.Add(TimeSpan.FromMilliseconds(500)); | ||||||
|  |     options.RetryDelays.Add(TimeSpan.FromSeconds(2)); | ||||||
|  |  | ||||||
|  |     options.AllowOfflineCacheFallback = true; | ||||||
|  |     options.OfflineCacheTolerance = TimeSpan.FromMinutes(5); | ||||||
|  | }); | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | > **Reminder:** `AddStellaOpsAuthClient` binds the options via `IOptionsMonitor<T>` so changes picked up from configuration reloads will be applied to future HTTP calls without restarting the host. | ||||||
|  |  | ||||||
|  | ## 2. Resilience options | ||||||
|  |  | ||||||
|  | | Option | Default | Notes | | ||||||
|  | |--------|---------|-------| | ||||||
|  | | `EnableRetries` | `true` | When disabled, the shared Polly policy is a no-op and HTTP calls will fail fast. | | ||||||
|  | | `RetryDelays` | `1s, 2s, 5s` | Edit in ascending order; zero/negative entries are ignored. Clearing the list and leaving it empty keeps the defaults. | | ||||||
|  | | `AllowOfflineCacheFallback` | `true` | When `true`, stale discovery/JWKS responses are reused within the tolerance window if Authority is unreachable. | | ||||||
|  | | `OfflineCacheTolerance` | `00:10:00` | Added to the normal cache lifetime. E.g. a 10 minute JWKS cache plus 5 minute tolerance keeps keys for 15 minutes if Authority is offline. | | ||||||
|  |  | ||||||
|  | The HTTP retry policy handles: | ||||||
|  |  | ||||||
|  | - 5xx responses | ||||||
|  | - 429 responses | ||||||
|  | - Transient transport failures (`HttpRequestException`, timeouts, aborted sockets) | ||||||
|  |  | ||||||
|  | Retries emit warnings via the `StellaOps.Auth.Client.HttpRetry` logger. Tune the delay values to honour your deployment’s SLOs. | ||||||
|  |  | ||||||
|  | ## 3. Configuration mapping | ||||||
|  |  | ||||||
|  | Suggested configuration keys (coordinate with consuming teams before finalising): | ||||||
|  |  | ||||||
|  | ```yaml | ||||||
|  | StellaOps: | ||||||
|  |   Authority: | ||||||
|  |     Url: "https://authority.stella-ops.local" | ||||||
|  |     ClientId: "feedser" | ||||||
|  |     ClientSecret: "change-me" | ||||||
|  |     AuthClient: | ||||||
|  |       EnableRetries: true | ||||||
|  |       RetryDelays: | ||||||
|  |         - "00:00:01" | ||||||
|  |         - "00:00:02" | ||||||
|  |         - "00:00:05" | ||||||
|  |       AllowOfflineCacheFallback: true | ||||||
|  |       OfflineCacheTolerance: "00:10:00" | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | Environment variable binding follows the usual double-underscore rules, e.g. | ||||||
|  |  | ||||||
|  | ``` | ||||||
|  | STELLAOPS__AUTHORITY__AUTHCLIENT__RETRYDELAYS__0=00:00:02 | ||||||
|  | STELLAOPS__AUTHORITY__AUTHCLIENT__OFFLINECACHETOLERANCE=00:05:00 | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | CLI and Feedser teams should expose these knobs once they adopt the auth client. | ||||||
|  |  | ||||||
|  | ## 4. Testing recommendations | ||||||
|  |  | ||||||
|  | 1. **Unit tests:** assert option binding by configuring `StellaOpsAuthClientOptions` via a `ConfigurationBuilder` and ensuring `Validate()` normalises the retry delays and scope list. | ||||||
|  | 2. **Offline fallback:** simulate an unreachable Authority by swapping `HttpMessageHandler` to throw `HttpRequestException` after priming the discovery/JWKS caches. Verify that tokens are still issued until the tolerance expires. | ||||||
|  | 3. **Observability:** watch for `StellaOps.Auth.Client.HttpRetry` warnings in your logs. Excessive retries mean the upstream Authority cluster needs attention. | ||||||
|  | 4. **Determinism:** keep retry delays deterministic. Avoid random jitter—operators can introduce jitter at the infrastructure layer if desired. | ||||||
|  |  | ||||||
|  | ## 5. Rollout checklist | ||||||
|  |  | ||||||
|  | - [ ] Update consuming service/CLI configuration schema to include the new settings. | ||||||
|  | - [ ] Document recommended defaults for offline (air-gapped) versus connected deployments. | ||||||
|  | - [ ] Extend smoke tests to cover Authority outage scenarios. | ||||||
|  | - [ ] Coordinate with Docs Guild so user-facing quickstarts reference the new knobs. | ||||||
|  |  | ||||||
|  | Once Feedser and CLI integrate these changes, we can mark LIB5 **DONE**; further packaging work is deferred until the backlog reintroduces it. | ||||||
							
								
								
									
										97
									
								
								docs/ops/authority-backup-restore.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								docs/ops/authority-backup-restore.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,97 @@ | |||||||
|  | # Authority Backup & Restore Runbook | ||||||
|  |  | ||||||
|  | ## Scope | ||||||
|  | - **Applies to:** StellaOps Authority deployments running the official `ops/authority/docker-compose.authority.yaml` stack or equivalent Kubernetes packaging. | ||||||
|  | - **Artifacts covered:** MongoDB (`stellaops-authority` database), Authority configuration (`etc/authority.yaml`), plugin manifests under `etc/authority.plugins/`, and signing key material stored in the `authority-keys` volume (defaults to `/app/keys` inside the container). | ||||||
|  | - **Frequency:** Run the full procedure prior to upgrades, before rotating keys, and at least once per 24 h in production. Store snapshots in an encrypted, access-controlled vault. | ||||||
|  |  | ||||||
|  | ## Inventory Checklist | ||||||
|  | | Component | Location (compose default) | Notes | | ||||||
|  | | --- | --- | --- | | ||||||
|  | | Mongo data | `mongo-data` volume (`/var/lib/docker/volumes/.../mongo-data`) | Contains all Authority collections (`AuthorityUser`, `AuthorityClient`, `AuthorityToken`, etc.). | | ||||||
|  | | Configuration | `etc/authority.yaml` | Mounted read-only into the container at `/etc/authority.yaml`. | | ||||||
|  | | Plugin manifests | `etc/authority.plugins/*.yaml` | Includes `standard.yaml` with `tokenSigning.keyDirectory`. | | ||||||
|  | | Signing keys | `authority-keys` volume -> `/app/keys` | Path is derived from `tokenSigning.keyDirectory` (defaults to `../keys` relative to the manifest). | | ||||||
|  |  | ||||||
|  | > **TIP:** Confirm the deployed key directory via `tokenSigning.keyDirectory` in `etc/authority.plugins/standard.yaml`; some installations relocate keys to `/var/lib/stellaops/authority/keys`. | ||||||
|  |  | ||||||
|  | ## Hot Backup (no downtime) | ||||||
|  | 1. **Create output directory:** `mkdir -p backup/$(date +%Y-%m-%d)` on the host. | ||||||
|  | 2. **Dump Mongo:** | ||||||
|  |    ```bash | ||||||
|  |    docker compose -f ops/authority/docker-compose.authority.yaml exec mongo \ | ||||||
|  |      mongodump --archive=/dump/authority-$(date +%Y%m%dT%H%M%SZ).gz \ | ||||||
|  |      --gzip --db stellaops-authority | ||||||
|  |    docker compose -f ops/authority/docker-compose.authority.yaml cp \ | ||||||
|  |      mongo:/dump/authority-$(date +%Y%m%dT%H%M%SZ).gz backup/ | ||||||
|  |    ``` | ||||||
|  |    The `mongodump` archive preserves indexes and can be restored with `mongorestore --archive --gzip`. | ||||||
|  | 3. **Capture configuration + manifests:** | ||||||
|  |    ```bash | ||||||
|  |    cp etc/authority.yaml backup/ | ||||||
|  |    rsync -a etc/authority.plugins/ backup/authority.plugins/ | ||||||
|  |    ``` | ||||||
|  | 4. **Export signing keys:** the compose file maps `authority-keys` to a local Docker volume. Snapshot it without stopping the service: | ||||||
|  |    ```bash | ||||||
|  |    docker run --rm \ | ||||||
|  |      -v authority-keys:/keys \ | ||||||
|  |      -v "$(pwd)/backup:/backup" \ | ||||||
|  |      busybox tar czf /backup/authority-keys-$(date +%Y%m%dT%H%M%SZ).tar.gz -C /keys . | ||||||
|  |    ``` | ||||||
|  | 5. **Checksum:** generate SHA-256 digests for every file and store them alongside the artefacts. | ||||||
|  | 6. **Encrypt & upload:** wrap the backup folder using your secrets management standard (e.g., age, GPG) and upload to the designated offline vault. | ||||||
|  |  | ||||||
|  | ## Cold Backup (planned downtime) | ||||||
|  | 1. Notify stakeholders and drain traffic (CLI clients should refresh tokens afterwards). | ||||||
|  | 2. Stop services: | ||||||
|  |    ```bash | ||||||
|  |    docker compose -f ops/authority/docker-compose.authority.yaml down | ||||||
|  |    ``` | ||||||
|  | 3. Back up volumes directly using `tar`: | ||||||
|  |    ```bash | ||||||
|  |    docker run --rm -v mongo-data:/data -v "$(pwd)/backup:/backup" \ | ||||||
|  |      busybox tar czf /backup/mongo-data-$(date +%Y%m%d).tar.gz -C /data . | ||||||
|  |    docker run --rm -v authority-keys:/keys -v "$(pwd)/backup:/backup" \ | ||||||
|  |      busybox tar czf /backup/authority-keys-$(date +%Y%m%d).tar.gz -C /keys . | ||||||
|  |    ``` | ||||||
|  | 4. Copy configuration + manifests as in the hot backup (steps 3–6). | ||||||
|  | 5. Restart services and verify health: | ||||||
|  |    ```bash | ||||||
|  |    docker compose -f ops/authority/docker-compose.authority.yaml up -d | ||||||
|  |    curl -fsS http://localhost:8080/ready | ||||||
|  |    ``` | ||||||
|  |  | ||||||
|  | ## Restore Procedure | ||||||
|  | 1. **Provision clean volumes:** remove existing volumes if you’re rebuilding a node (`docker volume rm mongo-data authority-keys`), then recreate the compose stack so empty volumes exist. | ||||||
|  | 2. **Restore Mongo:** | ||||||
|  |    ```bash | ||||||
|  |    docker compose exec -T mongo mongorestore --archive --gzip --drop < backup/authority-YYYYMMDDTHHMMSSZ.gz | ||||||
|  |    ``` | ||||||
|  |    Use `--drop` to replace collections; omit if doing a partial restore. | ||||||
|  | 3. **Restore configuration/manifests:** copy `authority.yaml` and `authority.plugins/*` into place before starting the Authority container. | ||||||
|  | 4. **Restore signing keys:** untar into the mounted volume: | ||||||
|  |    ```bash | ||||||
|  |    docker run --rm -v authority-keys:/keys -v "$(pwd)/backup:/backup" \ | ||||||
|  |      busybox tar xzf /backup/authority-keys-YYYYMMDD.tar.gz -C /keys | ||||||
|  |    ``` | ||||||
|  |    Ensure file permissions remain `600` for private keys (`chmod -R 600`). | ||||||
|  | 5. **Start services & validate:** | ||||||
|  |    ```bash | ||||||
|  |    docker compose up -d | ||||||
|  |    curl -fsS http://localhost:8080/health | ||||||
|  |    ``` | ||||||
|  | 6. **Validate JWKS and tokens:** call `/jwks` and issue a short-lived token via the CLI to confirm key material matches expectations. | ||||||
|  |  | ||||||
|  | ## Disaster Recovery Notes | ||||||
|  | - **Air-gapped replication:** replicate archives via the Offline Update Kit transport channels; never attach USB devices without scanning. | ||||||
|  | - **Retention:** maintain 30 daily snapshots + 12 monthly archival copies. Rotate encryption keys annually. | ||||||
|  | - **Key compromise:** if signing keys are suspected compromised, restore from the latest clean backup, rotate via OPS3 (key rotation tooling), and publish a revocation notice. | ||||||
|  | - **Mongo version:** keep dump/restore images pinned to the deployment version (compose uses `mongo:7`). Restoring across major versions requires a compatibility review. | ||||||
|  |  | ||||||
|  | ## Verification Checklist | ||||||
|  | - [ ] `/ready` reports all identity providers ready. | ||||||
|  | - [ ] OAuth flows issue tokens signed by the restored keys. | ||||||
|  | - [ ] `PluginRegistrationSummary` logs expected providers on startup. | ||||||
|  | - [ ] Revocation manifest export (`dotnet run --project src/StellaOps.Authority`) succeeds. | ||||||
|  | - [ ] Monitoring dashboards show metrics resuming (see OPS5 deliverables). | ||||||
|  |  | ||||||
							
								
								
									
										174
									
								
								docs/ops/authority-grafana-dashboard.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										174
									
								
								docs/ops/authority-grafana-dashboard.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,174 @@ | |||||||
|  | { | ||||||
|  |   "title": "StellaOps Authority - Token & Access Monitoring", | ||||||
|  |   "uid": "authority-token-monitoring", | ||||||
|  |   "schemaVersion": 38, | ||||||
|  |   "version": 1, | ||||||
|  |   "editable": true, | ||||||
|  |   "timezone": "", | ||||||
|  |   "graphTooltip": 0, | ||||||
|  |   "time": { | ||||||
|  |     "from": "now-6h", | ||||||
|  |     "to": "now" | ||||||
|  |   }, | ||||||
|  |   "templating": { | ||||||
|  |     "list": [ | ||||||
|  |       { | ||||||
|  |         "name": "datasource", | ||||||
|  |         "type": "datasource", | ||||||
|  |         "query": "prometheus", | ||||||
|  |         "refresh": 1, | ||||||
|  |         "hide": 0, | ||||||
|  |         "current": {} | ||||||
|  |       } | ||||||
|  |     ] | ||||||
|  |   }, | ||||||
|  |   "panels": [ | ||||||
|  |     { | ||||||
|  |       "id": 1, | ||||||
|  |       "title": "Token Requests – Success vs Failure", | ||||||
|  |       "type": "timeseries", | ||||||
|  |       "datasource": { | ||||||
|  |         "type": "prometheus", | ||||||
|  |         "uid": "${datasource}" | ||||||
|  |       }, | ||||||
|  |       "fieldConfig": { | ||||||
|  |         "defaults": { | ||||||
|  |           "unit": "req/s", | ||||||
|  |           "displayName": "{{grant_type}} ({{status}})" | ||||||
|  |         }, | ||||||
|  |         "overrides": [] | ||||||
|  |       }, | ||||||
|  |       "targets": [ | ||||||
|  |         { | ||||||
|  |           "refId": "A", | ||||||
|  |           "expr": "sum by (grant_type, status) (rate(http_server_duration_seconds_count{service_name=\"stellaops-authority\", http_route=\"/token\"}[5m]))", | ||||||
|  |           "legendFormat": "{{grant_type}} {{status}}" | ||||||
|  |         } | ||||||
|  |       ], | ||||||
|  |       "options": { | ||||||
|  |         "legend": { | ||||||
|  |           "displayMode": "table", | ||||||
|  |           "placement": "bottom" | ||||||
|  |         }, | ||||||
|  |         "tooltip": { | ||||||
|  |           "mode": "multi" | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       "id": 2, | ||||||
|  |       "title": "Rate Limiter Rejections", | ||||||
|  |       "type": "timeseries", | ||||||
|  |       "datasource": { | ||||||
|  |         "type": "prometheus", | ||||||
|  |         "uid": "${datasource}" | ||||||
|  |       }, | ||||||
|  |       "fieldConfig": { | ||||||
|  |         "defaults": { | ||||||
|  |           "unit": "req/s", | ||||||
|  |           "displayName": "{{limiter}}" | ||||||
|  |         }, | ||||||
|  |         "overrides": [] | ||||||
|  |       }, | ||||||
|  |       "targets": [ | ||||||
|  |         { | ||||||
|  |           "refId": "A", | ||||||
|  |           "expr": "sum by (limiter) (rate(aspnetcore_rate_limiting_rejections_total{service_name=\"stellaops-authority\"}[5m]))", | ||||||
|  |           "legendFormat": "{{limiter}}" | ||||||
|  |         } | ||||||
|  |       ] | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       "id": 3, | ||||||
|  |       "title": "Bypass Events (5m)", | ||||||
|  |       "type": "stat", | ||||||
|  |       "datasource": { | ||||||
|  |         "type": "prometheus", | ||||||
|  |         "uid": "${datasource}" | ||||||
|  |       }, | ||||||
|  |       "fieldConfig": { | ||||||
|  |         "defaults": { | ||||||
|  |           "unit": "short", | ||||||
|  |           "color": { | ||||||
|  |             "mode": "thresholds" | ||||||
|  |           }, | ||||||
|  |           "thresholds": { | ||||||
|  |             "mode": "absolute", | ||||||
|  |             "steps": [ | ||||||
|  |               { "color": "green", "value": null }, | ||||||
|  |               { "color": "orange", "value": 1 }, | ||||||
|  |               { "color": "red", "value": 5 } | ||||||
|  |             ] | ||||||
|  |           } | ||||||
|  |         }, | ||||||
|  |         "overrides": [] | ||||||
|  |       }, | ||||||
|  |       "targets": [ | ||||||
|  |         { | ||||||
|  |           "refId": "A", | ||||||
|  |           "expr": "sum(rate(log_messages_total{message_template=\"Granting StellaOps bypass for remote {RemoteIp}; required scopes {RequiredScopes}.\"}[5m]))" | ||||||
|  |         } | ||||||
|  |       ], | ||||||
|  |       "options": { | ||||||
|  |         "reduceOptions": { | ||||||
|  |           "calcs": ["last"], | ||||||
|  |           "fields": "", | ||||||
|  |           "values": false | ||||||
|  |         }, | ||||||
|  |         "orientation": "horizontal", | ||||||
|  |         "textMode": "auto" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       "id": 4, | ||||||
|  |       "title": "Lockout Events (15m)", | ||||||
|  |       "type": "stat", | ||||||
|  |       "datasource": { | ||||||
|  |         "type": "prometheus", | ||||||
|  |         "uid": "${datasource}" | ||||||
|  |       }, | ||||||
|  |       "fieldConfig": { | ||||||
|  |         "defaults": { | ||||||
|  |           "unit": "short", | ||||||
|  |           "color": { | ||||||
|  |             "mode": "thresholds" | ||||||
|  |           }, | ||||||
|  |           "thresholds": { | ||||||
|  |             "mode": "absolute", | ||||||
|  |             "steps": [ | ||||||
|  |               { "color": "green", "value": null }, | ||||||
|  |               { "color": "orange", "value": 5 }, | ||||||
|  |               { "color": "red", "value": 10 } | ||||||
|  |             ] | ||||||
|  |           } | ||||||
|  |         }, | ||||||
|  |         "overrides": [] | ||||||
|  |       }, | ||||||
|  |       "targets": [ | ||||||
|  |         { | ||||||
|  |           "refId": "A", | ||||||
|  |           "expr": "sum(rate(log_messages_total{message_template=\"Plugin {PluginName} denied access for {Username} due to lockout (retry after {RetryAfter}).\"}[15m]))" | ||||||
|  |         } | ||||||
|  |       ], | ||||||
|  |       "options": { | ||||||
|  |         "reduceOptions": { | ||||||
|  |           "calcs": ["last"], | ||||||
|  |           "fields": "", | ||||||
|  |           "values": false | ||||||
|  |         }, | ||||||
|  |         "orientation": "horizontal", | ||||||
|  |         "textMode": "auto" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       "id": 5, | ||||||
|  |       "title": "Trace Explorer Shortcut", | ||||||
|  |       "type": "text", | ||||||
|  |       "options": { | ||||||
|  |         "mode": "markdown", | ||||||
|  |         "content": "[Open Trace Explorer](#/explore?left={\"datasource\":\"tempo\",\"queries\":[{\"query\":\"{service.name=\\\"stellaops-authority\\\", span_name=~\\\"authority.token.*\\\"}\",\"refId\":\"A\"}]})" | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   ], | ||||||
|  |   "links": [] | ||||||
|  | } | ||||||
							
								
								
									
										81
									
								
								docs/ops/authority-monitoring.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								docs/ops/authority-monitoring.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,81 @@ | |||||||
|  | # Authority Monitoring & Alerting Playbook | ||||||
|  |  | ||||||
|  | ## Telemetry Sources | ||||||
|  | - **Traces:** Activity source `StellaOps.Authority` emits spans for every token flow (`authority.token.validate_*`, `authority.token.handle_*`, `authority.token.validate_access`). Key tags include `authority.endpoint`, `authority.grant_type`, `authority.username`, `authority.client_id`, and `authority.identity_provider`. | ||||||
|  | - **Metrics:** OpenTelemetry instrumentation (`AddAspNetCoreInstrumentation`, `AddHttpClientInstrumentation`, custom meter `StellaOps.Authority`) exports: | ||||||
|  |   - `http.server.request.duration` histogram (`http_route`, `http_status_code`, `authority.endpoint` tag via `aspnetcore` enrichment). | ||||||
|  |   - `process.runtime.gc.*`, `process.runtime.dotnet.*` (from `AddRuntimeInstrumentation`). | ||||||
|  | - **Logs:** Serilog writes structured events to stdout. Notable templates: | ||||||
|  |   - `"Password grant verification failed ..."` and `"Plugin {PluginName} denied access ... due to lockout"` (lockout spike detector). | ||||||
|  |   - `"Granting StellaOps bypass for remote {RemoteIp}"` (bypass usage). | ||||||
|  |   - `"Rate limit exceeded for path {Path} from {RemoteIp}"` (limiter alerts). | ||||||
|  |  | ||||||
|  | ## Prometheus Metrics to Collect | ||||||
|  | | Metric | Query | Purpose | | ||||||
|  | | --- | --- | --- | | ||||||
|  | | `token_requests_total` | `sum by (grant_type, status) (rate(http_server_duration_seconds_count{service_name="stellaops-authority", http_route="/token"}[5m]))` | Token issuance volume per grant type (`grant_type` comes via `authority.grant_type` span attribute → Exemplars in Grafana). | | ||||||
|  | | `token_failure_ratio` | `sum(rate(http_server_duration_seconds_count{service_name="stellaops-authority", http_route="/token", http_status_code=~"4..|5.."}[5m])) / sum(rate(http_server_duration_seconds_count{service_name="stellaops-authority", http_route="/token"}[5m]))` | Alert when > 5 % for 10 min. | | ||||||
|  | | `authorize_rate_limit_hits` | `sum(rate(aspnetcore_rate_limiting_rejections_total{service_name="stellaops-authority", limiter="authority-token"}[5m]))` | Detect rate limiting saturations (requires OTEL ASP.NET rate limiter exporter). | | ||||||
|  | | `lockout_events` | `sum by (plugin) (rate(log_messages_total{app="stellaops-authority", level="Warning", message_template="Plugin {PluginName} denied access for {Username} due to lockout (retry after {RetryAfter})."}[5m]))` | Derived from Loki/Promtail log counter. | | ||||||
|  | | `bypass_usage_total` | `sum(rate(log_messages_total{app="stellaops-authority", level="Information", message_template="Granting StellaOps bypass for remote {RemoteIp}; required scopes {RequiredScopes}."}[5m]))` | Track trusted bypass invocations. | | ||||||
|  |  | ||||||
|  | > **Exporter note:** Enable `aspnetcore` meters (`dotnet-counters` name `Microsoft.AspNetCore.Hosting`), or configure the OpenTelemetry Collector `metrics` pipeline with `metric_statements` to remap histogram counts into the shown series. | ||||||
|  |  | ||||||
|  | ## Alert Rules | ||||||
|  | 1. **Token Failure Surge** | ||||||
|  |    - _Expression_: `token_failure_ratio > 0.05` | ||||||
|  |    - _For_: `10m` | ||||||
|  |    - _Labels_: `severity="critical"` | ||||||
|  |    - _Annotations_: Include `topk(5, sum by (authority_identity_provider) (increase(authority_token_rejections_total[10m])))` as diagnostic hint (requires span → metric transformation). | ||||||
|  | 2. **Lockout Spike** | ||||||
|  |    - _Expression_: `sum(rate(log_messages_total{message_template="Plugin {PluginName} denied access for {Username} due to lockout (retry after {RetryAfter})."}[15m])) > 10` | ||||||
|  |    - _For_: `15m` | ||||||
|  |    - Investigate credential stuffing; consider temporarily tightening `RateLimiting.Token`. | ||||||
|  | 3. **Bypass Threshold** | ||||||
|  |    - _Expression_: `sum(rate(log_messages_total{message_template="Granting StellaOps bypass for remote {RemoteIp}; required scopes {RequiredScopes}."}[5m])) > 1` | ||||||
|  |    - _For_: `5m` | ||||||
|  |    - Alert severity `warning` — verify the calling host list. | ||||||
|  | 4. **Rate Limiter Saturation** | ||||||
|  |    - _Expression_: `sum(rate(aspnetcore_rate_limiting_rejections_total{service_name="stellaops-authority"}[5m])) > 0` | ||||||
|  |    - Escalate if sustained for 5 min; confirm trusted clients aren’t misconfigured. | ||||||
|  |  | ||||||
|  | ## Grafana Dashboard | ||||||
|  | - Import `docs/ops/authority-grafana-dashboard.json` to provision baseline panels: | ||||||
|  |   - **Token Success vs Failure** – stacked rate visualization split by grant type. | ||||||
|  |   - **Rate Limiter Hits** – bar chart showing `authority-token` and `authority-authorize`. | ||||||
|  |   - **Bypass & Lockout Events** – dual-stat panel using Loki-derived counters. | ||||||
|  |   - **Trace Explorer Link** – panel links to `StellaOps.Authority` span search pre-filtered by `authority.grant_type`. | ||||||
|  |  | ||||||
|  | ## Collector Configuration Snippets | ||||||
|  | ```yaml | ||||||
|  | receivers: | ||||||
|  |   otlp: | ||||||
|  |     protocols: | ||||||
|  |       http: | ||||||
|  | exporters: | ||||||
|  |   prometheus: | ||||||
|  |     endpoint: "0.0.0.0:9464" | ||||||
|  | processors: | ||||||
|  |   batch: | ||||||
|  |   attributes/token_grant: | ||||||
|  |     actions: | ||||||
|  |       - key: grant_type | ||||||
|  |         action: upsert | ||||||
|  |         from_attribute: authority.grant_type | ||||||
|  | service: | ||||||
|  |   pipelines: | ||||||
|  |     metrics: | ||||||
|  |       receivers: [otlp] | ||||||
|  |       processors: [attributes/token_grant, batch] | ||||||
|  |       exporters: [prometheus] | ||||||
|  |     logs: | ||||||
|  |       receivers: [otlp] | ||||||
|  |       processors: [batch] | ||||||
|  |       exporters: [loki] | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ## Operational Checklist | ||||||
|  | - [ ] Confirm `STELLAOPS_AUTHORITY__OBSERVABILITY__EXPORTERS` enables OTLP in production builds. | ||||||
|  | - [ ] Ensure Promtail captures container stdout with Serilog structured formatting. | ||||||
|  | - [ ] Periodically validate alert noise by running load tests that trigger the rate limiter. | ||||||
|  | - [ ] Include dashboard JSON in Offline Kit for air-gapped clusters; update version header when metrics change. | ||||||
| @@ -17,5 +17,6 @@ lockout: | |||||||
|  |  | ||||||
| tokenSigning: | tokenSigning: | ||||||
|   # Path to the directory containing signing keys (relative paths resolve |   # Path to the directory containing signing keys (relative paths resolve | ||||||
|   # against this configuration file location). |   # against the location of this manifest, environment variables are expanded, | ||||||
|  |   # and the final value is normalised to an absolute path during startup. | ||||||
|   keyDirectory: "../keys" |   keyDirectory: "../keys" | ||||||
|   | |||||||
| @@ -38,6 +38,9 @@ telemetry: | |||||||
|  |  | ||||||
| authority: | authority: | ||||||
|   enabled: false |   enabled: false | ||||||
|  |   # Temporary rollout flag. When true, Feedser logs anonymous access but does not fail requests | ||||||
|  |   # without tokens. Set to false before 2025-12-31 UTC to enforce authentication fully. | ||||||
|  |   allowAnonymousFallback: true | ||||||
|   # Issuer advertised by StellaOps Authority (e.g. https://authority.stella-ops.local). |   # Issuer advertised by StellaOps Authority (e.g. https://authority.stella-ops.local). | ||||||
|   issuer: "https://authority.stella-ops.local" |   issuer: "https://authority.stella-ops.local" | ||||||
|   # Optional explicit metadata address; defaults to {issuer}/.well-known/openid-configuration. |   # Optional explicit metadata address; defaults to {issuer}/.well-known/openid-configuration. | ||||||
| @@ -49,6 +52,13 @@ authority: | |||||||
|     - "api://feedser" |     - "api://feedser" | ||||||
|   requiredScopes: |   requiredScopes: | ||||||
|     - "feedser.jobs.trigger" |     - "feedser.jobs.trigger" | ||||||
|  |   # Outbound credentials Feedser can use to call Authority (client credentials flow). | ||||||
|  |   clientId: "feedser-jobs" | ||||||
|  |   # Prefer storing the secret outside of the config file. Provide either clientSecret or clientSecretFile. | ||||||
|  |   clientSecret: "" | ||||||
|  |   clientSecretFile: "" | ||||||
|  |   clientScopes: | ||||||
|  |     - "feedser.jobs.trigger" | ||||||
|   # Networks allowed to bypass authentication (loopback by default for on-host cron jobs). |   # Networks allowed to bypass authentication (loopback by default for on-host cron jobs). | ||||||
|   bypassNetworks: |   bypassNetworks: | ||||||
|     - "127.0.0.1/32" |     - "127.0.0.1/32" | ||||||
|   | |||||||
							
								
								
									
										16
									
								
								ops/authority/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								ops/authority/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | |||||||
|  | # Authority DevOps Crew | ||||||
|  |  | ||||||
|  | ## Mission | ||||||
|  | Operate and harden the StellaOps Authority platform in production and air-gapped environments: container images, deployment assets, observability defaults, backup/restore, and runtime key management. | ||||||
|  |  | ||||||
|  | ## Focus Areas | ||||||
|  | - **Build & Packaging** – Dockerfiles, OCI bundles, offline artefact refresh. | ||||||
|  | - **Deployment Tooling** – Compose/Kubernetes manifests, secrets bootstrap, upgrade paths. | ||||||
|  | - **Observability** – Logging defaults, metrics/trace exporters, dashboards, alert policies. | ||||||
|  | - **Continuity & Security** – Backup/restore guides, key rotation playbooks, revocation propagation. | ||||||
|  |  | ||||||
|  | ## Working Agreements | ||||||
|  | - Track work in `ops/authority/TASKS.md` (TODO → DOING → DONE/BLOCKED); keep entries dated. | ||||||
|  | - Validate container changes with the CI pipeline (`ops/authority` GitHub workflow) before marking DONE. | ||||||
|  | - Update operator documentation in `docs/` together with any behavioural change. | ||||||
|  | - Coordinate with Authority Core and Security Guild before altering sensitive defaults (rate limits, crypto providers, revocation jobs). | ||||||
| @@ -14,7 +14,7 @@ WORKDIR /src | |||||||
|  |  | ||||||
| # Restore & publish | # Restore & publish | ||||||
| COPY . . | COPY . . | ||||||
| RUN dotnet restore StellaOps.sln | RUN dotnet restore src/StellaOps.sln | ||||||
| RUN dotnet publish src/StellaOps.Authority/StellaOps.Authority/StellaOps.Authority.csproj \ | RUN dotnet publish src/StellaOps.Authority/StellaOps.Authority/StellaOps.Authority.csproj \ | ||||||
|     -c Release \ |     -c Release \ | ||||||
|     -o /app/publish \ |     -o /app/publish \ | ||||||
|   | |||||||
							
								
								
									
										6
									
								
								ops/authority/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								ops/authority/TASKS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | |||||||
|  | # Authority DevOps Task Board (UTC 2025-10-10) | ||||||
|  |  | ||||||
|  | | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||||
|  | | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||||
|  | |----|--------|----------|------------|-------------|---------------| | ||||||
|  | | OPS3.KEY-ROTATION | BLOCKED | DevOps Crew, Authority Core | CORE10.JWKS | Implement key rotation tooling + pipeline hook once rotating JWKS lands. Document SOP and secret handling. | ✅ CLI/script rotates keys + updates JWKS; ✅ Pipeline job documented; ✅ docs/ops runbook updated. | | ||||||
							
								
								
									
										20
									
								
								src/StellaOps.Authority/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								src/StellaOps.Authority/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | |||||||
|  | # Authority Host Crew | ||||||
|  |  | ||||||
|  | ## Mission | ||||||
|  | Own the StellaOps Authority host service: ASP.NET minimal API, OpenIddict flows, plugin loading, storage orchestration, and cross-cutting security controls (rate limiting, audit logging, revocation exports). | ||||||
|  |  | ||||||
|  | ## Teams On Call | ||||||
|  | - Team 2 (Authority Core) | ||||||
|  | - Team 8 (Security Guild) — collaborates on security-sensitive endpoints | ||||||
|  |  | ||||||
|  | ## Operating Principles | ||||||
|  | - Deterministic responses, structured logging, cancellation-ready handlers. | ||||||
|  | - Use `StellaOps.Cryptography` abstractions for any crypto operations. | ||||||
|  | - Every change updates `TASKS.md` and related docs/tests. | ||||||
|  | - Coordinate with plugin teams before altering plugin-facing contracts. | ||||||
|  |  | ||||||
|  | ## Key Directories | ||||||
|  | - `src/StellaOps.Authority/` — host app | ||||||
|  | - `src/StellaOps.Authority.Tests/` — integration/unit tests | ||||||
|  | - `src/StellaOps.Authority.Storage.Mongo/` — data access helpers | ||||||
|  | - `src/StellaOps.Authority.Plugin.Standard/` — default identity provider plugin | ||||||
| @@ -0,0 +1,9 @@ | |||||||
|  | # StellaOps.Auth.Abstractions | ||||||
|  |  | ||||||
|  | Shared authentication primitives for StellaOps services: | ||||||
|  |  | ||||||
|  | - Scope and claim constants aligned with StellaOps Authority. | ||||||
|  | - Deterministic `PrincipalBuilder` and `ProblemResultFactory` helpers. | ||||||
|  | - Utility types used by resource servers, plug-ins, and client libraries. | ||||||
|  |  | ||||||
|  | These abstractions are referenced by `StellaOps.Auth.ServerIntegration` and `StellaOps.Auth.Client`. Review `docs/dev/32_AUTH_CLIENT_GUIDE.md` for downstream integration patterns. | ||||||
| @@ -6,7 +6,32 @@ | |||||||
|     <Nullable>enable</Nullable> |     <Nullable>enable</Nullable> | ||||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> |     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||||
|   </PropertyGroup> |   </PropertyGroup> | ||||||
|  |   <PropertyGroup> | ||||||
|  |     <PackageId>StellaOps.Auth.Abstractions</PackageId> | ||||||
|  |     <Description>Core authority authentication abstractions, scopes, and helpers for StellaOps services.</Description> | ||||||
|  |     <Authors>StellaOps</Authors> | ||||||
|  |     <Company>StellaOps</Company> | ||||||
|  |     <PackageLicenseExpression>AGPL-3.0-or-later</PackageLicenseExpression> | ||||||
|  |     <PackageProjectUrl>https://stella-ops.org</PackageProjectUrl> | ||||||
|  |     <RepositoryUrl>https://git.stella-ops.org/stella-ops.org/git.stella-ops.org</RepositoryUrl> | ||||||
|  |     <RepositoryType>git</RepositoryType> | ||||||
|  |     <PublishRepositoryUrl>true</PublishRepositoryUrl> | ||||||
|  |     <EmbedUntrackedSources>true</EmbedUntrackedSources> | ||||||
|  |     <IncludeSymbols>true</IncludeSymbols> | ||||||
|  |     <SymbolPackageFormat>snupkg</SymbolPackageFormat> | ||||||
|  |     <PackageTags>stellaops;authentication;authority;oauth2</PackageTags> | ||||||
|  |     <GenerateDocumentationFile>true</GenerateDocumentationFile> | ||||||
|  |     <NoWarn>$(NoWarn);1591</NoWarn> | ||||||
|  |     <PackageReadmeFile>README.NuGet.md</PackageReadmeFile> | ||||||
|  |     <VersionPrefix>1.0.0-preview.1</VersionPrefix> | ||||||
|  |   </PropertyGroup> | ||||||
|   <ItemGroup> |   <ItemGroup> | ||||||
|     <FrameworkReference Include="Microsoft.AspNetCore.App" /> |     <FrameworkReference Include="Microsoft.AspNetCore.App" /> | ||||||
|   </ItemGroup> |   </ItemGroup> | ||||||
|  |   <ItemGroup> | ||||||
|  |     <PackageReference Include="Microsoft.SourceLink.GitLab" Version="8.0.0" PrivateAssets="All" /> | ||||||
|  |   </ItemGroup> | ||||||
|  |   <ItemGroup> | ||||||
|  |     <None Include="README.NuGet.md" Pack="true" PackagePath="" /> | ||||||
|  |   </ItemGroup> | ||||||
| </Project> | </Project> | ||||||
|   | |||||||
| @@ -0,0 +1,95 @@ | |||||||
|  | using System; | ||||||
|  | using System.Collections.Generic; | ||||||
|  | using System.Linq; | ||||||
|  | using System.Net; | ||||||
|  | using System.Net.Http; | ||||||
|  | using System.Net.Http.Headers; | ||||||
|  | using System.Threading; | ||||||
|  | using System.Threading.Tasks; | ||||||
|  | using Microsoft.Extensions.DependencyInjection; | ||||||
|  | using Microsoft.Extensions.Http; | ||||||
|  | using StellaOps.Auth.Client; | ||||||
|  | using Xunit; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Auth.Client.Tests; | ||||||
|  |  | ||||||
|  | public class ServiceCollectionExtensionsTests | ||||||
|  | { | ||||||
|  |     [Fact] | ||||||
|  |     public async Task AddStellaOpsAuthClient_ConfiguresRetryPolicy() | ||||||
|  |     { | ||||||
|  |         var services = new ServiceCollection(); | ||||||
|  |         services.AddLogging(); | ||||||
|  |  | ||||||
|  |         services.AddStellaOpsAuthClient(options => | ||||||
|  |         { | ||||||
|  |             options.Authority = "https://authority.test"; | ||||||
|  |             options.RetryDelays.Clear(); | ||||||
|  |             options.RetryDelays.Add(TimeSpan.FromMilliseconds(1)); | ||||||
|  |             options.DiscoveryCacheLifetime = TimeSpan.FromMinutes(1); | ||||||
|  |             options.JwksCacheLifetime = TimeSpan.FromMinutes(1); | ||||||
|  |             options.AllowOfflineCacheFallback = false; | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         var recordedHandlers = new List<DelegatingHandler>(); | ||||||
|  |         var attemptCount = 0; | ||||||
|  |  | ||||||
|  |         services.AddHttpClient<StellaOpsDiscoveryCache>() | ||||||
|  |             .ConfigureHttpMessageHandlerBuilder(builder => | ||||||
|  |             { | ||||||
|  |                 recordedHandlers = new List<DelegatingHandler>(builder.AdditionalHandlers); | ||||||
|  |  | ||||||
|  |                 var responses = new Queue<Func<HttpResponseMessage>>(new[] | ||||||
|  |                 { | ||||||
|  |                     () => CreateResponse(HttpStatusCode.InternalServerError, "{}"), | ||||||
|  |                     () => CreateResponse(HttpStatusCode.OK, "{\"token_endpoint\":\"https://authority.test/connect/token\",\"jwks_uri\":\"https://authority.test/jwks\"}") | ||||||
|  |                 }); | ||||||
|  |  | ||||||
|  |                 builder.PrimaryHandler = new LambdaHttpMessageHandler((_, _) => | ||||||
|  |                 { | ||||||
|  |                     attemptCount++; | ||||||
|  |  | ||||||
|  |                     if (responses.Count == 0) | ||||||
|  |                     { | ||||||
|  |                         return Task.FromResult(CreateResponse(HttpStatusCode.OK, "{}")); | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     var factory = responses.Dequeue(); | ||||||
|  |                     return Task.FromResult(factory()); | ||||||
|  |                 }); | ||||||
|  |             }); | ||||||
|  |  | ||||||
|  |         using var provider = services.BuildServiceProvider(); | ||||||
|  |  | ||||||
|  |         var cache = provider.GetRequiredService<StellaOpsDiscoveryCache>(); | ||||||
|  |         var configuration = await cache.GetAsync(CancellationToken.None); | ||||||
|  |  | ||||||
|  |         Assert.Equal(new Uri("https://authority.test/connect/token"), configuration.TokenEndpoint); | ||||||
|  |         Assert.Equal(2, attemptCount); | ||||||
|  |         Assert.NotEmpty(recordedHandlers); | ||||||
|  |         Assert.Contains(recordedHandlers, handler => handler.GetType().Name.Contains("PolicyHttpMessageHandler", StringComparison.Ordinal)); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static HttpResponseMessage CreateResponse(HttpStatusCode statusCode, string jsonContent) | ||||||
|  |     { | ||||||
|  |         return new HttpResponseMessage(statusCode) | ||||||
|  |         { | ||||||
|  |             Content = new StringContent(jsonContent) | ||||||
|  |             { | ||||||
|  |                 Headers = { ContentType = new MediaTypeHeaderValue("application/json") } | ||||||
|  |             } | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  |     private sealed class LambdaHttpMessageHandler : HttpMessageHandler | ||||||
|  |     { | ||||||
|  |         private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder; | ||||||
|  |  | ||||||
|  |         public LambdaHttpMessageHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder) | ||||||
|  |         { | ||||||
|  |             this.responder = responder; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) | ||||||
|  |             => responder(request, cancellationToken); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -8,4 +8,8 @@ | |||||||
|     <ProjectReference Include="..\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj" /> |     <ProjectReference Include="..\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj" /> | ||||||
|     <ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" /> |     <ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" /> | ||||||
|   </ItemGroup> |   </ItemGroup> | ||||||
|  |   <ItemGroup> | ||||||
|  |     <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.5" /> | ||||||
|  |     <PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" /> | ||||||
|  |   </ItemGroup> | ||||||
| </Project> | </Project> | ||||||
|   | |||||||
| @@ -23,6 +23,7 @@ public class StellaOpsAuthClientOptionsTests | |||||||
|  |  | ||||||
|         Assert.Equal(new[] { "authority.users.manage", "feedser.jobs.trigger" }, options.NormalizedScopes); |         Assert.Equal(new[] { "authority.users.manage", "feedser.jobs.trigger" }, options.NormalizedScopes); | ||||||
|         Assert.Equal(new Uri("https://authority.test"), options.AuthorityUri); |         Assert.Equal(new Uri("https://authority.test"), options.AuthorityUri); | ||||||
|  |         Assert.Equal<TimeSpan>(options.RetryDelays, options.NormalizedRetryDelays); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     [Fact] |     [Fact] | ||||||
| @@ -34,4 +35,50 @@ public class StellaOpsAuthClientOptionsTests | |||||||
|  |  | ||||||
|         Assert.Contains("Authority", exception.Message, StringComparison.OrdinalIgnoreCase); |         Assert.Contains("Authority", exception.Message, StringComparison.OrdinalIgnoreCase); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     [Fact] | ||||||
|  |     public void Validate_NormalizesRetryDelays() | ||||||
|  |     { | ||||||
|  |         var options = new StellaOpsAuthClientOptions | ||||||
|  |         { | ||||||
|  |             Authority = "https://authority.test" | ||||||
|  |         }; | ||||||
|  |         options.RetryDelays.Clear(); | ||||||
|  |         options.RetryDelays.Add(TimeSpan.Zero); | ||||||
|  |         options.RetryDelays.Add(TimeSpan.FromSeconds(3)); | ||||||
|  |         options.RetryDelays.Add(TimeSpan.FromMilliseconds(-1)); | ||||||
|  |  | ||||||
|  |         options.Validate(); | ||||||
|  |  | ||||||
|  |         Assert.Equal<TimeSpan>(new[] { TimeSpan.FromSeconds(3) }, options.NormalizedRetryDelays); | ||||||
|  |         Assert.Equal<TimeSpan>(options.NormalizedRetryDelays, options.RetryDelays); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [Fact] | ||||||
|  |     public void Validate_DisabledRetries_ProducesEmptyDelays() | ||||||
|  |     { | ||||||
|  |         var options = new StellaOpsAuthClientOptions | ||||||
|  |         { | ||||||
|  |             Authority = "https://authority.test", | ||||||
|  |             EnableRetries = false | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         options.Validate(); | ||||||
|  |  | ||||||
|  |         Assert.Empty(options.NormalizedRetryDelays); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [Fact] | ||||||
|  |     public void Validate_Throws_When_OfflineToleranceNegative() | ||||||
|  |     { | ||||||
|  |         var options = new StellaOpsAuthClientOptions | ||||||
|  |         { | ||||||
|  |             Authority = "https://authority.test", | ||||||
|  |             OfflineCacheTolerance = TimeSpan.FromSeconds(-1) | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         var exception = Assert.Throws<InvalidOperationException>(() => options.Validate()); | ||||||
|  |  | ||||||
|  |         Assert.Contains("Offline cache tolerance", exception.Message, StringComparison.OrdinalIgnoreCase); | ||||||
|  |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -0,0 +1,134 @@ | |||||||
|  | using System; | ||||||
|  | using System.Net; | ||||||
|  | using System.Net.Http; | ||||||
|  | using System.Net.Http.Headers; | ||||||
|  | using System.Threading; | ||||||
|  | using System.Threading.Tasks; | ||||||
|  | using Microsoft.Extensions.Logging.Abstractions; | ||||||
|  | using Microsoft.Extensions.Options; | ||||||
|  | using Microsoft.Extensions.Time.Testing; | ||||||
|  | using StellaOps.Auth.Client; | ||||||
|  | using Xunit; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Auth.Client.Tests; | ||||||
|  |  | ||||||
|  | public class StellaOpsDiscoveryCacheTests | ||||||
|  | { | ||||||
|  |     [Fact] | ||||||
|  |     public async Task GetAsync_UsesOfflineFallbackWithinTolerance() | ||||||
|  |     { | ||||||
|  |         var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-01-01T00:00:00Z")); | ||||||
|  |         var callCount = 0; | ||||||
|  |         var handler = new StubHttpMessageHandler((request, _) => | ||||||
|  |         { | ||||||
|  |             callCount++; | ||||||
|  |  | ||||||
|  |             if (callCount == 1) | ||||||
|  |             { | ||||||
|  |                 return Task.FromResult(CreateJsonResponse("{\"token_endpoint\":\"https://authority.test/connect/token\",\"jwks_uri\":\"https://authority.test/jwks\"}")); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             throw new HttpRequestException("offline"); | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         var httpClient = new HttpClient(handler); | ||||||
|  |  | ||||||
|  |         var options = new StellaOpsAuthClientOptions | ||||||
|  |         { | ||||||
|  |             Authority = "https://authority.test", | ||||||
|  |             DiscoveryCacheLifetime = TimeSpan.FromMinutes(1), | ||||||
|  |             OfflineCacheTolerance = TimeSpan.FromMinutes(5), | ||||||
|  |             AllowOfflineCacheFallback = true | ||||||
|  |         }; | ||||||
|  |         options.Validate(); | ||||||
|  |  | ||||||
|  |         var monitor = new TestOptionsMonitor<StellaOpsAuthClientOptions>(options); | ||||||
|  |         var cache = new StellaOpsDiscoveryCache(httpClient, monitor, timeProvider, NullLogger<StellaOpsDiscoveryCache>.Instance); | ||||||
|  |  | ||||||
|  |         var configuration = await cache.GetAsync(CancellationToken.None); | ||||||
|  |         Assert.Equal(new Uri("https://authority.test/connect/token"), configuration.TokenEndpoint); | ||||||
|  |  | ||||||
|  |         timeProvider.Advance(TimeSpan.FromMinutes(1) + TimeSpan.FromSeconds(5)); | ||||||
|  |  | ||||||
|  |         configuration = await cache.GetAsync(CancellationToken.None); | ||||||
|  |         Assert.Equal(new Uri("https://authority.test/connect/token"), configuration.TokenEndpoint); | ||||||
|  |         Assert.Equal(2, callCount); | ||||||
|  |  | ||||||
|  |         var offlineExpiry = GetOfflineExpiry(cache); | ||||||
|  |         Assert.True(offlineExpiry > timeProvider.GetUtcNow()); | ||||||
|  |  | ||||||
|  |         timeProvider.Advance(options.OfflineCacheTolerance + TimeSpan.FromSeconds(1)); | ||||||
|  |  | ||||||
|  |         Assert.True(offlineExpiry < timeProvider.GetUtcNow()); | ||||||
|  |  | ||||||
|  |         HttpRequestException? exception = null; | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             await cache.GetAsync(CancellationToken.None); | ||||||
|  |         } | ||||||
|  |         catch (HttpRequestException ex) | ||||||
|  |         { | ||||||
|  |             exception = ex; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         Assert.NotNull(exception); | ||||||
|  |         Assert.Equal(3, callCount); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static HttpResponseMessage CreateJsonResponse(string json) | ||||||
|  |     { | ||||||
|  |         return new HttpResponseMessage(HttpStatusCode.OK) | ||||||
|  |         { | ||||||
|  |             Content = new StringContent(json) | ||||||
|  |             { | ||||||
|  |                 Headers = { ContentType = new MediaTypeHeaderValue("application/json") } | ||||||
|  |             } | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private sealed class StubHttpMessageHandler : HttpMessageHandler | ||||||
|  |     { | ||||||
|  |         private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder; | ||||||
|  |  | ||||||
|  |         public StubHttpMessageHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder) | ||||||
|  |         { | ||||||
|  |             this.responder = responder; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) | ||||||
|  |             => responder(request, cancellationToken); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private sealed class TestOptionsMonitor<T> : IOptionsMonitor<T> | ||||||
|  |         where T : class | ||||||
|  |     { | ||||||
|  |         private readonly T value; | ||||||
|  |  | ||||||
|  |         public TestOptionsMonitor(T value) | ||||||
|  |         { | ||||||
|  |             this.value = value; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public T CurrentValue => value; | ||||||
|  |  | ||||||
|  |         public T Get(string? name) => value; | ||||||
|  |  | ||||||
|  |         public IDisposable OnChange(Action<T, string> listener) => NullDisposable.Instance; | ||||||
|  |  | ||||||
|  |         private sealed class NullDisposable : IDisposable | ||||||
|  |         { | ||||||
|  |             public static NullDisposable Instance { get; } = new(); | ||||||
|  |  | ||||||
|  |             public void Dispose() | ||||||
|  |             { | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static DateTimeOffset GetOfflineExpiry(StellaOpsDiscoveryCache cache) | ||||||
|  |     { | ||||||
|  |         var field = typeof(StellaOpsDiscoveryCache).GetField("offlineExpiresAt", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); | ||||||
|  |         Assert.NotNull(field); | ||||||
|  |         return (DateTimeOffset)field!.GetValue(cache)!; | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,9 @@ | |||||||
|  | # StellaOps.Auth.Client | ||||||
|  |  | ||||||
|  | Typed OpenID Connect client used by StellaOps services, agents, and tooling to talk to **StellaOps Authority**. It provides: | ||||||
|  |  | ||||||
|  | - Discovery + JWKS caching with deterministic refresh windows. | ||||||
|  | - Password and client-credential flows with token cache abstractions. | ||||||
|  | - Configurable HTTP retry/backoff policies (Polly) and offline fallback support for air-gapped deployments. | ||||||
|  |  | ||||||
|  | See `docs/dev/32_AUTH_CLIENT_GUIDE.md` in the repository for integration guidance, option descriptions, and rollout checklists. | ||||||
| @@ -1,7 +1,12 @@ | |||||||
| using System; | using System; | ||||||
|  | using System.Net; | ||||||
|  | using System.Net.Http; | ||||||
| using Microsoft.Extensions.DependencyInjection; | using Microsoft.Extensions.DependencyInjection; | ||||||
| using Microsoft.Extensions.DependencyInjection.Extensions; | using Microsoft.Extensions.DependencyInjection.Extensions; | ||||||
|  | using Microsoft.Extensions.Logging; | ||||||
| using Microsoft.Extensions.Options; | using Microsoft.Extensions.Options; | ||||||
|  | using Polly; | ||||||
|  | using Polly.Extensions.Http; | ||||||
|  |  | ||||||
| namespace StellaOps.Auth.Client; | namespace StellaOps.Auth.Client; | ||||||
|  |  | ||||||
| @@ -28,19 +33,19 @@ public static class ServiceCollectionExtensions | |||||||
|         { |         { | ||||||
|             var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue; |             var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue; | ||||||
|             client.Timeout = options.HttpTimeout; |             client.Timeout = options.HttpTimeout; | ||||||
|         }); |         }).AddPolicyHandler(static (provider, _) => CreateRetryPolicy(provider)); | ||||||
|  |  | ||||||
|         services.AddHttpClient<StellaOpsJwksCache>((provider, client) => |         services.AddHttpClient<StellaOpsJwksCache>((provider, client) => | ||||||
|         { |         { | ||||||
|             var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue; |             var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue; | ||||||
|             client.Timeout = options.HttpTimeout; |             client.Timeout = options.HttpTimeout; | ||||||
|         }); |         }).AddPolicyHandler(static (provider, _) => CreateRetryPolicy(provider)); | ||||||
|  |  | ||||||
|         services.AddHttpClient<IStellaOpsTokenClient, StellaOpsTokenClient>((provider, client) => |         services.AddHttpClient<IStellaOpsTokenClient, StellaOpsTokenClient>((provider, client) => | ||||||
|         { |         { | ||||||
|             var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue; |             var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue; | ||||||
|             client.Timeout = options.HttpTimeout; |             client.Timeout = options.HttpTimeout; | ||||||
|         }); |         }).AddPolicyHandler(static (provider, _) => CreateRetryPolicy(provider)); | ||||||
|  |  | ||||||
|         return services; |         return services; | ||||||
|     } |     } | ||||||
| @@ -62,4 +67,49 @@ public static class ServiceCollectionExtensions | |||||||
|  |  | ||||||
|         return services; |         return services; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     private static IAsyncPolicy<HttpResponseMessage> CreateRetryPolicy(IServiceProvider provider) | ||||||
|  |     { | ||||||
|  |         var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue; | ||||||
|  |         var delays = options.NormalizedRetryDelays; | ||||||
|  |         if (delays.Count == 0) | ||||||
|  |         { | ||||||
|  |             return Policy.NoOpAsync<HttpResponseMessage>(); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var logger = provider.GetService<ILoggerFactory>()?.CreateLogger("StellaOps.Auth.Client.HttpRetry"); | ||||||
|  |  | ||||||
|  |         return HttpPolicyExtensions | ||||||
|  |             .HandleTransientHttpError() | ||||||
|  |             .OrResult(static response => response.StatusCode == HttpStatusCode.TooManyRequests) | ||||||
|  |             .WaitAndRetryAsync( | ||||||
|  |                 delays.Count, | ||||||
|  |                 attempt => delays[attempt - 1], | ||||||
|  |                 (outcome, delay, attempt, _) => | ||||||
|  |                 { | ||||||
|  |                     if (logger is null) | ||||||
|  |                     { | ||||||
|  |                         return; | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     if (outcome.Exception is not null) | ||||||
|  |                     { | ||||||
|  |                         logger.LogWarning( | ||||||
|  |                             outcome.Exception, | ||||||
|  |                             "Retrying Authority HTTP call ({Attempt}/{TotalAttempts}) after exception; waiting {Delay}.", | ||||||
|  |                             attempt, | ||||||
|  |                             delays.Count, | ||||||
|  |                             delay); | ||||||
|  |                     } | ||||||
|  |                     else | ||||||
|  |                     { | ||||||
|  |                         logger.LogWarning( | ||||||
|  |                             "Retrying Authority HTTP call ({Attempt}/{TotalAttempts}) due to status {StatusCode}; waiting {Delay}.", | ||||||
|  |                             attempt, | ||||||
|  |                             delays.Count, | ||||||
|  |                             outcome.Result!.StatusCode, | ||||||
|  |                             delay); | ||||||
|  |                     } | ||||||
|  |                 }); | ||||||
|  |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -6,14 +6,38 @@ | |||||||
|     <Nullable>enable</Nullable> |     <Nullable>enable</Nullable> | ||||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> |     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||||
|   </PropertyGroup> |   </PropertyGroup> | ||||||
|  |   <PropertyGroup> | ||||||
|  |     <PackageId>StellaOps.Auth.Client</PackageId> | ||||||
|  |     <Description>Typed OAuth/OpenID client for StellaOps Authority with caching, retries, and token helpers.</Description> | ||||||
|  |     <Authors>StellaOps</Authors> | ||||||
|  |     <Company>StellaOps</Company> | ||||||
|  |     <PackageLicenseExpression>AGPL-3.0-or-later</PackageLicenseExpression> | ||||||
|  |     <PackageProjectUrl>https://stella-ops.org</PackageProjectUrl> | ||||||
|  |     <RepositoryUrl>https://git.stella-ops.org/stella-ops.org/git.stella-ops.org</RepositoryUrl> | ||||||
|  |     <RepositoryType>git</RepositoryType> | ||||||
|  |     <PublishRepositoryUrl>true</PublishRepositoryUrl> | ||||||
|  |     <EmbedUntrackedSources>true</EmbedUntrackedSources> | ||||||
|  |     <IncludeSymbols>true</IncludeSymbols> | ||||||
|  |     <SymbolPackageFormat>snupkg</SymbolPackageFormat> | ||||||
|  |     <PackageTags>stellaops;authentication;authority;oauth2;client</PackageTags> | ||||||
|  |     <GenerateDocumentationFile>true</GenerateDocumentationFile> | ||||||
|  |     <NoWarn>$(NoWarn);1591</NoWarn> | ||||||
|  |     <PackageReadmeFile>README.NuGet.md</PackageReadmeFile> | ||||||
|  |     <VersionPrefix>1.0.0-preview.1</VersionPrefix> | ||||||
|  |   </PropertyGroup> | ||||||
|   <ItemGroup> |   <ItemGroup> | ||||||
|     <ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" /> |     <ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" /> | ||||||
|     <ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" /> |     <ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" /> | ||||||
|   </ItemGroup> |   </ItemGroup> | ||||||
|   <ItemGroup> |   <ItemGroup> | ||||||
|  |     <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.5" /> | ||||||
|  |     <PackageReference Include="Microsoft.SourceLink.GitLab" Version="8.0.0" PrivateAssets="All" /> | ||||||
|     <FrameworkReference Include="Microsoft.AspNetCore.App" /> |     <FrameworkReference Include="Microsoft.AspNetCore.App" /> | ||||||
|     <PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.0.1" /> |     <PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.0.1" /> | ||||||
|   </ItemGroup> |   </ItemGroup> | ||||||
|  |   <ItemGroup> | ||||||
|  |     <None Include="README.NuGet.md" Pack="true" PackagePath="" /> | ||||||
|  |   </ItemGroup> | ||||||
|   <ItemGroup> |   <ItemGroup> | ||||||
|     <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo"> |     <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo"> | ||||||
|       <_Parameter1>StellaOps.Auth.Client.Tests</_Parameter1> |       <_Parameter1>StellaOps.Auth.Client.Tests</_Parameter1> | ||||||
|   | |||||||
| @@ -10,7 +10,16 @@ namespace StellaOps.Auth.Client; | |||||||
| /// </summary> | /// </summary> | ||||||
| public sealed class StellaOpsAuthClientOptions | public sealed class StellaOpsAuthClientOptions | ||||||
| { | { | ||||||
|  |     private static readonly TimeSpan[] DefaultRetryDelays = | ||||||
|  |     { | ||||||
|  |         TimeSpan.FromSeconds(1), | ||||||
|  |         TimeSpan.FromSeconds(2), | ||||||
|  |         TimeSpan.FromSeconds(5) | ||||||
|  |     }; | ||||||
|  |     private static readonly TimeSpan DefaultOfflineTolerance = TimeSpan.FromMinutes(10); | ||||||
|  |  | ||||||
|     private readonly List<string> scopes = new(); |     private readonly List<string> scopes = new(); | ||||||
|  |     private readonly List<TimeSpan> retryDelays = new(DefaultRetryDelays); | ||||||
|  |  | ||||||
|     /// <summary> |     /// <summary> | ||||||
|     /// Authority (issuer) base URL. |     /// Authority (issuer) base URL. | ||||||
| @@ -32,6 +41,16 @@ public sealed class StellaOpsAuthClientOptions | |||||||
|     /// </summary> |     /// </summary> | ||||||
|     public IList<string> DefaultScopes => scopes; |     public IList<string> DefaultScopes => scopes; | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Retry delays applied by HTTP retry policy (empty uses defaults). | ||||||
|  |     /// </summary> | ||||||
|  |     public IList<TimeSpan> RetryDelays => retryDelays; | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Gets or sets a value indicating whether HTTP retry policies are enabled. | ||||||
|  |     /// </summary> | ||||||
|  |     public bool EnableRetries { get; set; } = true; | ||||||
|  |  | ||||||
|     /// <summary> |     /// <summary> | ||||||
|     /// Timeout applied to discovery and token HTTP requests. |     /// Timeout applied to discovery and token HTTP requests. | ||||||
|     /// </summary> |     /// </summary> | ||||||
| @@ -52,6 +71,16 @@ public sealed class StellaOpsAuthClientOptions | |||||||
|     /// </summary> |     /// </summary> | ||||||
|     public TimeSpan ExpirationSkew { get; set; } = TimeSpan.FromSeconds(30); |     public TimeSpan ExpirationSkew { get; set; } = TimeSpan.FromSeconds(30); | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Gets or sets a value indicating whether cached discovery/JWKS responses may be served when the Authority is unreachable. | ||||||
|  |     /// </summary> | ||||||
|  |     public bool AllowOfflineCacheFallback { get; set; } = true; | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Additional tolerance window during which stale cache entries remain valid if offline fallback is allowed. | ||||||
|  |     /// </summary> | ||||||
|  |     public TimeSpan OfflineCacheTolerance { get; set; } = DefaultOfflineTolerance; | ||||||
|  |  | ||||||
|     /// <summary> |     /// <summary> | ||||||
|     /// Parsed Authority URI (populated after validation). |     /// Parsed Authority URI (populated after validation). | ||||||
|     /// </summary> |     /// </summary> | ||||||
| @@ -62,6 +91,11 @@ public sealed class StellaOpsAuthClientOptions | |||||||
|     /// </summary> |     /// </summary> | ||||||
|     public IReadOnlyList<string> NormalizedScopes { get; private set; } = Array.Empty<string>(); |     public IReadOnlyList<string> NormalizedScopes { get; private set; } = Array.Empty<string>(); | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Normalised retry delays (populated after validation). | ||||||
|  |     /// </summary> | ||||||
|  |     public IReadOnlyList<TimeSpan> NormalizedRetryDelays { get; private set; } = Array.Empty<TimeSpan>(); | ||||||
|  |  | ||||||
|     /// <summary> |     /// <summary> | ||||||
|     /// Validates required values and normalises scope entries. |     /// Validates required values and normalises scope entries. | ||||||
|     /// </summary> |     /// </summary> | ||||||
| @@ -97,8 +131,14 @@ public sealed class StellaOpsAuthClientOptions | |||||||
|             throw new InvalidOperationException("Expiration skew must be between 0 seconds and 5 minutes."); |             throw new InvalidOperationException("Expiration skew must be between 0 seconds and 5 minutes."); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         if (OfflineCacheTolerance < TimeSpan.Zero) | ||||||
|  |         { | ||||||
|  |             throw new InvalidOperationException("Offline cache tolerance must be greater than or equal to zero."); | ||||||
|  |         } | ||||||
|  |  | ||||||
|         AuthorityUri = authorityUri; |         AuthorityUri = authorityUri; | ||||||
|         NormalizedScopes = NormalizeScopes(scopes); |         NormalizedScopes = NormalizeScopes(scopes); | ||||||
|  |         NormalizedRetryDelays = EnableRetries ? NormalizeRetryDelays(retryDelays) : Array.Empty<TimeSpan>(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private static IReadOnlyList<string> NormalizeScopes(IList<string> values) |     private static IReadOnlyList<string> NormalizeScopes(IList<string> values) | ||||||
| @@ -140,4 +180,26 @@ public sealed class StellaOpsAuthClientOptions | |||||||
|             ? Array.Empty<string>() |             ? Array.Empty<string>() | ||||||
|             : values.OrderBy(static scope => scope, StringComparer.Ordinal).ToArray(); |             : values.OrderBy(static scope => scope, StringComparer.Ordinal).ToArray(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     private static IReadOnlyList<TimeSpan> NormalizeRetryDelays(IList<TimeSpan> values) | ||||||
|  |     { | ||||||
|  |         for (var index = values.Count - 1; index >= 0; index--) | ||||||
|  |         { | ||||||
|  |             var delay = values[index]; | ||||||
|  |             if (delay <= TimeSpan.Zero) | ||||||
|  |             { | ||||||
|  |                 values.RemoveAt(index); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (values.Count == 0) | ||||||
|  |         { | ||||||
|  |             foreach (var delay in DefaultRetryDelays) | ||||||
|  |             { | ||||||
|  |                 values.Add(delay); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return values.ToArray(); | ||||||
|  |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -21,6 +21,7 @@ public sealed class StellaOpsDiscoveryCache | |||||||
|  |  | ||||||
|     private OpenIdConfiguration? cachedConfiguration; |     private OpenIdConfiguration? cachedConfiguration; | ||||||
|     private DateTimeOffset cacheExpiresAt; |     private DateTimeOffset cacheExpiresAt; | ||||||
|  |     private DateTimeOffset offlineExpiresAt; | ||||||
|  |  | ||||||
|     public StellaOpsDiscoveryCache(HttpClient httpClient, IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor, TimeProvider? timeProvider = null, ILogger<StellaOpsDiscoveryCache>? logger = null) |     public StellaOpsDiscoveryCache(HttpClient httpClient, IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor, TimeProvider? timeProvider = null, ILogger<StellaOpsDiscoveryCache>? logger = null) | ||||||
|     { |     { | ||||||
| @@ -44,41 +45,95 @@ public sealed class StellaOpsDiscoveryCache | |||||||
|  |  | ||||||
|         logger?.LogDebug("Fetching StellaOps discovery document from {DiscoveryUri}.", discoveryUri); |         logger?.LogDebug("Fetching StellaOps discovery document from {DiscoveryUri}.", discoveryUri); | ||||||
|  |  | ||||||
|         using var request = new HttpRequestMessage(HttpMethod.Get, discoveryUri); |         try | ||||||
|         using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); |  | ||||||
|         response.EnsureSuccessStatusCode(); |  | ||||||
|  |  | ||||||
|         await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); |  | ||||||
|         var document = await JsonSerializer.DeserializeAsync<DiscoveryDocument>(stream, serializerOptions, cancellationToken).ConfigureAwait(false); |  | ||||||
|  |  | ||||||
|         if (document is null) |  | ||||||
|         { |         { | ||||||
|             throw new InvalidOperationException("Authority discovery document is empty."); |             using var request = new HttpRequestMessage(HttpMethod.Get, discoveryUri); | ||||||
|         } |             using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); | ||||||
|  |             response.EnsureSuccessStatusCode(); | ||||||
|  |  | ||||||
|         if (string.IsNullOrWhiteSpace(document.TokenEndpoint)) |             await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); | ||||||
|  |             var document = await JsonSerializer.DeserializeAsync<DiscoveryDocument>(stream, serializerOptions, cancellationToken).ConfigureAwait(false); | ||||||
|  |  | ||||||
|  |             if (document is null) | ||||||
|  |             { | ||||||
|  |                 throw new InvalidOperationException("Authority discovery document is empty."); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (string.IsNullOrWhiteSpace(document.TokenEndpoint)) | ||||||
|  |             { | ||||||
|  |                 throw new InvalidOperationException("Authority discovery document does not expose token_endpoint."); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (string.IsNullOrWhiteSpace(document.JwksUri)) | ||||||
|  |             { | ||||||
|  |                 throw new InvalidOperationException("Authority discovery document does not expose jwks_uri."); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var configuration = new OpenIdConfiguration( | ||||||
|  |                 new Uri(document.TokenEndpoint, UriKind.Absolute), | ||||||
|  |                 new Uri(document.JwksUri, UriKind.Absolute)); | ||||||
|  |  | ||||||
|  |             cachedConfiguration = configuration; | ||||||
|  |             cacheExpiresAt = now + options.DiscoveryCacheLifetime; | ||||||
|  |             offlineExpiresAt = cacheExpiresAt + options.OfflineCacheTolerance; | ||||||
|  |  | ||||||
|  |             return configuration; | ||||||
|  |         } | ||||||
|  |         catch (Exception exception) when (IsOfflineCandidate(exception, cancellationToken) && TryUseOfflineFallback(options, now, exception)) | ||||||
|         { |         { | ||||||
|             throw new InvalidOperationException("Authority discovery document does not expose token_endpoint."); |             return cachedConfiguration!; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if (string.IsNullOrWhiteSpace(document.JwksUri)) |  | ||||||
|         { |  | ||||||
|             throw new InvalidOperationException("Authority discovery document does not expose jwks_uri."); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         var configuration = new OpenIdConfiguration( |  | ||||||
|             new Uri(document.TokenEndpoint, UriKind.Absolute), |  | ||||||
|             new Uri(document.JwksUri, UriKind.Absolute)); |  | ||||||
|  |  | ||||||
|         cachedConfiguration = configuration; |  | ||||||
|         cacheExpiresAt = now + options.DiscoveryCacheLifetime; |  | ||||||
|  |  | ||||||
|         return configuration; |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private sealed record DiscoveryDocument( |     private sealed record DiscoveryDocument( | ||||||
|         [property: System.Text.Json.Serialization.JsonPropertyName("token_endpoint")] string? TokenEndpoint, |         [property: System.Text.Json.Serialization.JsonPropertyName("token_endpoint")] string? TokenEndpoint, | ||||||
|         [property: System.Text.Json.Serialization.JsonPropertyName("jwks_uri")] string? JwksUri); |         [property: System.Text.Json.Serialization.JsonPropertyName("jwks_uri")] string? JwksUri); | ||||||
|  |  | ||||||
|  |     private static bool IsOfflineCandidate(Exception exception, CancellationToken cancellationToken) | ||||||
|  |     { | ||||||
|  |         if (exception is HttpRequestException) | ||||||
|  |         { | ||||||
|  |             return true; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (exception is TaskCanceledException && !cancellationToken.IsCancellationRequested) | ||||||
|  |         { | ||||||
|  |             return true; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (exception is TimeoutException) | ||||||
|  |         { | ||||||
|  |             return true; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private bool TryUseOfflineFallback(StellaOpsAuthClientOptions options, DateTimeOffset now, Exception exception) | ||||||
|  |     { | ||||||
|  |         if (!options.AllowOfflineCacheFallback || cachedConfiguration is null) | ||||||
|  |         { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (options.OfflineCacheTolerance <= TimeSpan.Zero) | ||||||
|  |         { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (offlineExpiresAt == DateTimeOffset.MinValue) | ||||||
|  |         { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (now >= offlineExpiresAt) | ||||||
|  |         { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         logger?.LogWarning(exception, "Discovery document fetch failed; reusing cached configuration until {FallbackExpiresAt}.", offlineExpiresAt); | ||||||
|  |         return true; | ||||||
|  |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| /// <summary> | /// <summary> | ||||||
|   | |||||||
| @@ -21,6 +21,7 @@ public sealed class StellaOpsJwksCache | |||||||
|  |  | ||||||
|     private JsonWebKeySet? cachedSet; |     private JsonWebKeySet? cachedSet; | ||||||
|     private DateTimeOffset cacheExpiresAt; |     private DateTimeOffset cacheExpiresAt; | ||||||
|  |     private DateTimeOffset offlineExpiresAt; | ||||||
|  |  | ||||||
|     public StellaOpsJwksCache( |     public StellaOpsJwksCache( | ||||||
|         HttpClient httpClient, |         HttpClient httpClient, | ||||||
| @@ -44,17 +45,72 @@ public sealed class StellaOpsJwksCache | |||||||
|             return cachedSet; |             return cachedSet; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         var options = optionsMonitor.CurrentValue; | ||||||
|         var configuration = await discoveryCache.GetAsync(cancellationToken).ConfigureAwait(false); |         var configuration = await discoveryCache.GetAsync(cancellationToken).ConfigureAwait(false); | ||||||
|  |  | ||||||
|         logger?.LogDebug("Fetching JWKS from {JwksUri}.", configuration.JwksEndpoint); |         logger?.LogDebug("Fetching JWKS from {JwksUri}.", configuration.JwksEndpoint); | ||||||
|  |  | ||||||
|         using var response = await httpClient.GetAsync(configuration.JwksEndpoint, cancellationToken).ConfigureAwait(false); |         try | ||||||
|         response.EnsureSuccessStatusCode(); |         { | ||||||
|  |             using var response = await httpClient.GetAsync(configuration.JwksEndpoint, cancellationToken).ConfigureAwait(false); | ||||||
|  |             response.EnsureSuccessStatusCode(); | ||||||
|  |  | ||||||
|         var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); |             var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); | ||||||
|         cachedSet = new JsonWebKeySet(json); |             cachedSet = new JsonWebKeySet(json); | ||||||
|         cacheExpiresAt = now + optionsMonitor.CurrentValue.JwksCacheLifetime; |             cacheExpiresAt = now + options.JwksCacheLifetime; | ||||||
|  |             offlineExpiresAt = cacheExpiresAt + options.OfflineCacheTolerance; | ||||||
|  |  | ||||||
|         return cachedSet; |             return cachedSet; | ||||||
|  |         } | ||||||
|  |         catch (Exception exception) when (IsOfflineCandidate(exception, cancellationToken) && TryUseOfflineFallback(options, now, exception)) | ||||||
|  |         { | ||||||
|  |             return cachedSet!; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static bool IsOfflineCandidate(Exception exception, CancellationToken cancellationToken) | ||||||
|  |     { | ||||||
|  |         if (exception is HttpRequestException) | ||||||
|  |         { | ||||||
|  |             return true; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (exception is TaskCanceledException && !cancellationToken.IsCancellationRequested) | ||||||
|  |         { | ||||||
|  |             return true; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (exception is TimeoutException) | ||||||
|  |         { | ||||||
|  |             return true; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private bool TryUseOfflineFallback(StellaOpsAuthClientOptions options, DateTimeOffset now, Exception exception) | ||||||
|  |     { | ||||||
|  |         if (!options.AllowOfflineCacheFallback || cachedSet is null) | ||||||
|  |         { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (options.OfflineCacheTolerance <= TimeSpan.Zero) | ||||||
|  |         { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (offlineExpiresAt == DateTimeOffset.MinValue) | ||||||
|  |         { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (now >= offlineExpiresAt) | ||||||
|  |         { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         logger?.LogWarning(exception, "JWKS fetch failed; reusing cached keys until {FallbackExpiresAt}.", offlineExpiresAt); | ||||||
|  |         return true; | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -0,0 +1,9 @@ | |||||||
|  | # StellaOps.Auth.ServerIntegration | ||||||
|  |  | ||||||
|  | ASP.NET Core helpers that enable resource servers to authenticate with **StellaOps Authority**: | ||||||
|  |  | ||||||
|  | - `AddStellaOpsResourceServerAuthentication` extension for JWT bearer + scope policies. | ||||||
|  | - Network bypass mask evaluation for on-host automation. | ||||||
|  | - Consistent `ProblemDetails` responses and policy helpers shared with Feedser/Backend services. | ||||||
|  |  | ||||||
|  | Pair this package with `StellaOps.Auth.Abstractions` and `StellaOps.Auth.Client` for end-to-end Authority integration. | ||||||
| @@ -6,6 +6,25 @@ | |||||||
|     <Nullable>enable</Nullable> |     <Nullable>enable</Nullable> | ||||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> |     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||||
|   </PropertyGroup> |   </PropertyGroup> | ||||||
|  |   <PropertyGroup> | ||||||
|  |     <PackageId>StellaOps.Auth.ServerIntegration</PackageId> | ||||||
|  |     <Description>ASP.NET server integration helpers for StellaOps Authority, including JWT validation and bypass masks.</Description> | ||||||
|  |     <Authors>StellaOps</Authors> | ||||||
|  |     <Company>StellaOps</Company> | ||||||
|  |     <PackageLicenseExpression>AGPL-3.0-or-later</PackageLicenseExpression> | ||||||
|  |     <PackageProjectUrl>https://stella-ops.org</PackageProjectUrl> | ||||||
|  |     <RepositoryUrl>https://git.stella-ops.org/stella-ops.org/git.stella-ops.org</RepositoryUrl> | ||||||
|  |     <RepositoryType>git</RepositoryType> | ||||||
|  |     <PublishRepositoryUrl>true</PublishRepositoryUrl> | ||||||
|  |     <EmbedUntrackedSources>true</EmbedUntrackedSources> | ||||||
|  |     <IncludeSymbols>true</IncludeSymbols> | ||||||
|  |     <SymbolPackageFormat>snupkg</SymbolPackageFormat> | ||||||
|  |     <PackageTags>stellaops;authentication;authority;aspnet</PackageTags> | ||||||
|  |     <GenerateDocumentationFile>true</GenerateDocumentationFile> | ||||||
|  |     <NoWarn>$(NoWarn);1591</NoWarn> | ||||||
|  |     <PackageReadmeFile>README.NuGet.md</PackageReadmeFile> | ||||||
|  |     <VersionPrefix>1.0.0-preview.1</VersionPrefix> | ||||||
|  |   </PropertyGroup> | ||||||
|   <ItemGroup> |   <ItemGroup> | ||||||
|     <ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" /> |     <ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" /> | ||||||
|     <ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" /> |     <ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" /> | ||||||
| @@ -14,6 +33,10 @@ | |||||||
|   </ItemGroup> |   </ItemGroup> | ||||||
|   <ItemGroup> |   <ItemGroup> | ||||||
|     <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0-rc.1.25451.107" /> |     <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0-rc.1.25451.107" /> | ||||||
|  |     <PackageReference Include="Microsoft.SourceLink.GitLab" Version="8.0.0" PrivateAssets="All" /> | ||||||
|  |   </ItemGroup> | ||||||
|  |   <ItemGroup> | ||||||
|  |     <None Include="README.NuGet.md" Pack="true" PackagePath="" /> | ||||||
|   </ItemGroup> |   </ItemGroup> | ||||||
|   <ItemGroup> |   <ItemGroup> | ||||||
|     <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo"> |     <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo"> | ||||||
|   | |||||||
| @@ -1,4 +1,5 @@ | |||||||
| using System; | using System; | ||||||
|  | using System.IO; | ||||||
| using StellaOps.Authority.Plugin.Standard; | using StellaOps.Authority.Plugin.Standard; | ||||||
|  |  | ||||||
| namespace StellaOps.Authority.Plugin.Standard.Tests; | namespace StellaOps.Authority.Plugin.Standard.Tests; | ||||||
| @@ -53,4 +54,46 @@ public class StandardPluginOptionsTests | |||||||
|         var ex = Assert.Throws<InvalidOperationException>(() => options.Validate("standard")); |         var ex = Assert.Throws<InvalidOperationException>(() => options.Validate("standard")); | ||||||
|         Assert.Contains("lockout.windowMinutes", ex.Message, StringComparison.OrdinalIgnoreCase); |         Assert.Contains("lockout.windowMinutes", ex.Message, StringComparison.OrdinalIgnoreCase); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     [Fact] | ||||||
|  |     public void Normalize_ResolvesRelativeTokenSigningDirectory() | ||||||
|  |     { | ||||||
|  |         var configDir = Path.Combine(Path.GetTempPath(), "stellaops-standard-plugin", Guid.NewGuid().ToString("N")); | ||||||
|  |         Directory.CreateDirectory(configDir); | ||||||
|  |  | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             var configPath = Path.Combine(configDir, "standard.yaml"); | ||||||
|  |             var options = new StandardPluginOptions | ||||||
|  |             { | ||||||
|  |                 TokenSigning = { KeyDirectory = "../keys" } | ||||||
|  |             }; | ||||||
|  |  | ||||||
|  |             options.Normalize(configPath); | ||||||
|  |  | ||||||
|  |             var expected = Path.GetFullPath(Path.Combine(configDir, "../keys")); | ||||||
|  |             Assert.Equal(expected, options.TokenSigning.KeyDirectory); | ||||||
|  |         } | ||||||
|  |         finally | ||||||
|  |         { | ||||||
|  |             if (Directory.Exists(configDir)) | ||||||
|  |             { | ||||||
|  |                 Directory.Delete(configDir, recursive: true); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [Fact] | ||||||
|  |     public void Normalize_PreservesAbsoluteTokenSigningDirectory() | ||||||
|  |     { | ||||||
|  |         var absolute = Path.Combine(Path.GetTempPath(), "stellaops-standard-plugin", Guid.NewGuid().ToString("N"), "keys"); | ||||||
|  |         var options = new StandardPluginOptions | ||||||
|  |         { | ||||||
|  |             TokenSigning = { KeyDirectory = absolute } | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         options.Normalize(Path.Combine(Path.GetTempPath(), "config", "standard.yaml")); | ||||||
|  |  | ||||||
|  |         Assert.Equal(Path.GetFullPath(absolute), options.TokenSigning.KeyDirectory); | ||||||
|  |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,13 +1,16 @@ | |||||||
| using System; | using System; | ||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
|  | using System.IO; | ||||||
| using System.Threading; | using System.Threading; | ||||||
| using System.Threading.Tasks; | using System.Threading.Tasks; | ||||||
| using Microsoft.Extensions.Configuration; | using Microsoft.Extensions.Configuration; | ||||||
| using Microsoft.Extensions.DependencyInjection; | using Microsoft.Extensions.DependencyInjection; | ||||||
| using Microsoft.Extensions.Hosting; | using Microsoft.Extensions.Hosting; | ||||||
|  | using Microsoft.Extensions.Options; | ||||||
| using Mongo2Go; | using Mongo2Go; | ||||||
| using MongoDB.Driver; | using MongoDB.Driver; | ||||||
| using StellaOps.Authority.Plugins.Abstractions; | using StellaOps.Authority.Plugins.Abstractions; | ||||||
|  | using StellaOps.Authority.Plugin.Standard; | ||||||
| using StellaOps.Authority.Plugin.Standard.Bootstrap; | using StellaOps.Authority.Plugin.Standard.Bootstrap; | ||||||
| using StellaOps.Authority.Plugin.Standard.Storage; | using StellaOps.Authority.Plugin.Standard.Storage; | ||||||
| using StellaOps.Authority.Storage.Mongo.Documents; | using StellaOps.Authority.Storage.Mongo.Documents; | ||||||
| @@ -146,6 +149,61 @@ public class StandardPluginRegistrarTests | |||||||
|         using var provider = services.BuildServiceProvider(); |         using var provider = services.BuildServiceProvider(); | ||||||
|         Assert.Throws<InvalidOperationException>(() => provider.GetRequiredService<IIdentityProviderPlugin>()); |         Assert.Throws<InvalidOperationException>(() => provider.GetRequiredService<IIdentityProviderPlugin>()); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     [Fact] | ||||||
|  |     public void Register_NormalizesTokenSigningKeyDirectory() | ||||||
|  |     { | ||||||
|  |         using var runner = MongoDbRunner.Start(singleNodeReplSet: true); | ||||||
|  |         var client = new MongoClient(runner.ConnectionString); | ||||||
|  |         var database = client.GetDatabase("registrar-token-signing"); | ||||||
|  |  | ||||||
|  |         var configuration = new ConfigurationBuilder() | ||||||
|  |             .AddInMemoryCollection(new Dictionary<string, string?> | ||||||
|  |             { | ||||||
|  |                 ["tokenSigning:keyDirectory"] = "../keys" | ||||||
|  |             }) | ||||||
|  |             .Build(); | ||||||
|  |  | ||||||
|  |         var configDir = Path.Combine(Path.GetTempPath(), "stellaops-standard-plugin", Guid.NewGuid().ToString("N")); | ||||||
|  |         Directory.CreateDirectory(configDir); | ||||||
|  |  | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             var configPath = Path.Combine(configDir, "standard.yaml"); | ||||||
|  |             var manifest = new AuthorityPluginManifest( | ||||||
|  |                 "standard", | ||||||
|  |                 "standard", | ||||||
|  |                 true, | ||||||
|  |                 typeof(StandardPluginRegistrar).Assembly.GetName().Name, | ||||||
|  |                 typeof(StandardPluginRegistrar).Assembly.Location, | ||||||
|  |                 new[] { AuthorityPluginCapabilities.Password }, | ||||||
|  |                 new Dictionary<string, string?>(), | ||||||
|  |                 configPath); | ||||||
|  |  | ||||||
|  |             var pluginContext = new AuthorityPluginContext(manifest, configuration); | ||||||
|  |             var services = new ServiceCollection(); | ||||||
|  |             services.AddLogging(); | ||||||
|  |             services.AddSingleton<IMongoDatabase>(database); | ||||||
|  |             services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore()); | ||||||
|  |  | ||||||
|  |             var registrar = new StandardPluginRegistrar(); | ||||||
|  |             registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration)); | ||||||
|  |  | ||||||
|  |             using var provider = services.BuildServiceProvider(); | ||||||
|  |             var optionsMonitor = provider.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>(); | ||||||
|  |             var options = optionsMonitor.Get("standard"); | ||||||
|  |  | ||||||
|  |             var expected = Path.GetFullPath(Path.Combine(configDir, "../keys")); | ||||||
|  |             Assert.Equal(expected, options.TokenSigning.KeyDirectory); | ||||||
|  |         } | ||||||
|  |         finally | ||||||
|  |         { | ||||||
|  |             if (Directory.Exists(configDir)) | ||||||
|  |             { | ||||||
|  |                 Directory.Delete(configDir, recursive: true); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| internal sealed class InMemoryClientStore : IAuthorityClientStore | internal sealed class InMemoryClientStore : IAuthorityClientStore | ||||||
|   | |||||||
| @@ -0,0 +1,20 @@ | |||||||
|  | # Plugin Team Charter | ||||||
|  |  | ||||||
|  | ## Mission | ||||||
|  | Own the Mongo-backed Standard identity provider plug-in and shared Authority plug-in contracts. Deliver secure credential flows, configuration validation, and documentation that help other identity providers integrate cleanly. | ||||||
|  |  | ||||||
|  | ## Responsibilities | ||||||
|  | - Maintain `StellaOps.Authority.Plugin.Standard` and related test projects. | ||||||
|  | - Coordinate schema/option changes with Authority Core and Docs guilds. | ||||||
|  | - Ensure plugin options remain deterministic and offline-friendly. | ||||||
|  | - Surface open work on `TASKS.md`; update statuses (TODO/DOING/DONE/BLOCKED/REVIEW). | ||||||
|  |  | ||||||
|  | ## Key Paths | ||||||
|  | - `StandardPluginOptions` & registrar wiring | ||||||
|  | - `StandardUserCredentialStore` (Mongo persistence + lockouts) | ||||||
|  | - `docs/dev/31_AUTHORITY_PLUGIN_DEVELOPER_GUIDE.md` | ||||||
|  |  | ||||||
|  | ## Coordination | ||||||
|  | - Team 2 (Authority Core) for handler integration. | ||||||
|  | - Security Guild for password hashing, audit, revocation. | ||||||
|  | - Docs Guild for developer guide polish and diagrams. | ||||||
| @@ -1,4 +1,5 @@ | |||||||
| using System; | using System; | ||||||
|  | using System.IO; | ||||||
|  |  | ||||||
| namespace StellaOps.Authority.Plugin.Standard; | namespace StellaOps.Authority.Plugin.Standard; | ||||||
|  |  | ||||||
| @@ -12,6 +13,11 @@ internal sealed class StandardPluginOptions | |||||||
|  |  | ||||||
|     public TokenSigningOptions TokenSigning { get; set; } = new(); |     public TokenSigningOptions TokenSigning { get; set; } = new(); | ||||||
|  |  | ||||||
|  |     public void Normalize(string configPath) | ||||||
|  |     { | ||||||
|  |         TokenSigning.Normalize(configPath); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     public void Validate(string pluginName) |     public void Validate(string pluginName) | ||||||
|     { |     { | ||||||
|         BootstrapUser?.Validate(pluginName); |         BootstrapUser?.Validate(pluginName); | ||||||
| @@ -90,4 +96,35 @@ internal sealed class LockoutOptions | |||||||
| internal sealed class TokenSigningOptions | internal sealed class TokenSigningOptions | ||||||
| { | { | ||||||
|     public string? KeyDirectory { get; set; } |     public string? KeyDirectory { get; set; } | ||||||
|  |  | ||||||
|  |     public void Normalize(string configPath) | ||||||
|  |     { | ||||||
|  |         if (string.IsNullOrWhiteSpace(KeyDirectory)) | ||||||
|  |         { | ||||||
|  |             KeyDirectory = null; | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var resolved = KeyDirectory.Trim(); | ||||||
|  |         if (string.IsNullOrEmpty(resolved)) | ||||||
|  |         { | ||||||
|  |             KeyDirectory = null; | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         resolved = Environment.ExpandEnvironmentVariables(resolved); | ||||||
|  |  | ||||||
|  |         if (!Path.IsPathRooted(resolved)) | ||||||
|  |         { | ||||||
|  |             var baseDirectory = Path.GetDirectoryName(configPath); | ||||||
|  |             if (string.IsNullOrEmpty(baseDirectory)) | ||||||
|  |             { | ||||||
|  |                 baseDirectory = Directory.GetCurrentDirectory(); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             resolved = Path.Combine(baseDirectory, resolved); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         KeyDirectory = Path.GetFullPath(resolved); | ||||||
|  |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -29,9 +29,16 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar | |||||||
|         context.Services.AddSingleton<StandardClaimsEnricher>(); |         context.Services.AddSingleton<StandardClaimsEnricher>(); | ||||||
|         context.Services.AddSingleton<IClaimsEnricher>(sp => sp.GetRequiredService<StandardClaimsEnricher>()); |         context.Services.AddSingleton<IClaimsEnricher>(sp => sp.GetRequiredService<StandardClaimsEnricher>()); | ||||||
|  |  | ||||||
|  |         var configPath = context.Plugin.Manifest.ConfigPath; | ||||||
|  |  | ||||||
|         context.Services.AddOptions<StandardPluginOptions>(pluginName) |         context.Services.AddOptions<StandardPluginOptions>(pluginName) | ||||||
|             .Bind(context.Plugin.Configuration) |             .Bind(context.Plugin.Configuration) | ||||||
|             .PostConfigure(options => options.Validate(pluginName)); |             .PostConfigure(options => | ||||||
|  |             { | ||||||
|  |                 options.Normalize(configPath); | ||||||
|  |                 options.Validate(pluginName); | ||||||
|  |             }) | ||||||
|  |             .ValidateOnStart(); | ||||||
|  |  | ||||||
|         context.Services.AddSingleton(sp => |         context.Services.AddSingleton(sp => | ||||||
|         { |         { | ||||||
|   | |||||||
| @@ -0,0 +1,15 @@ | |||||||
|  | # Team 8 / Plugin Standard Backlog (UTC 2025-10-10) | ||||||
|  |  | ||||||
|  | | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||||
|  | |----|--------|----------|------------|-------------|---------------| | ||||||
|  | | PLG6.DOC | BLOCKED (Docs) | BE-Auth Plugin, Docs Guild | PLG1–PLG5 | Final polish + diagrams for plugin developer guide. | Docs team delivers copy-edit + exported diagrams; PR merged. | | ||||||
|  | | SEC1.PLG | TODO | Security Guild, BE-Auth Plugin | SEC1.A (StellaOps.Cryptography) | Swap Standard plugin hashing to Argon2id via `StellaOps.Cryptography` abstractions; keep PBKDF2 verification for legacy. | ✅ `StandardUserCredentialStore` uses `ICryptoProvider` to hash/check; ✅ Transparent rehash on success; ✅ Unit tests cover tamper + legacy rehash. | | ||||||
|  | | SEC1.OPT | TODO | Security Guild | SEC1.PLG | Expose password hashing knobs in `StandardPluginOptions` (`memoryKiB`, `iterations`, `parallelism`, `algorithm`) with validation. | ✅ Options bound from YAML; ✅ Invalid configs throw; ✅ Docs include tuning guidance. | | ||||||
|  | | SEC2.PLG | TODO | Security Guild, Storage Guild | SEC2.A (audit contract) | Emit audit events from password verification outcomes and persist via `IAuthorityLoginAttemptStore`. | ✅ Serilog events enriched with subject/client/IP/outcome; ✅ Mongo records written per attempt; ✅ Tests assert success/lockout/failure cases. | | ||||||
|  | | SEC3.PLG | TODO | Security Guild, BE-Auth Plugin | CORE8, SEC3.A (rate limiter) | Ensure lockout responses and rate-limit metadata flow through plugin logs/events (include retry-after). | ✅ Audit record includes retry-after; ✅ Tests confirm lockout + limiter interplay. | | ||||||
|  | | SEC4.PLG | TODO | Security Guild | SEC4.A (revocation schema) | Provide plugin hooks so revoked users/clients write reasons for revocation bundle export. | ✅ Revocation exporter consumes plugin data; ✅ Tests cover revoked user/client output. | | ||||||
|  | | SEC5.PLG | TODO | Security Guild | SEC5.A (threat model) | Address plugin-specific mitigations (bootstrap user handling, password policy docs) in threat model backlog. | ✅ Threat model lists plugin attack surfaces; ✅ Mitigation items filed. | | ||||||
|  | | PLG4-6.CAPABILITIES | DOING (2025-10-10) | BE-Auth Plugin, Docs Guild | PLG1–PLG3 | Finalise capability metadata exposure, config validation, and developer guide updates; remaining action is Docs polish/diagram export. | ✅ Capability metadata + validation merged; ✅ Plugin guide updated with final copy & diagrams; ✅ Release notes mention new toggles. | | ||||||
|  | | PLG7.RFC | REVIEW | BE-Auth Plugin, Security Guild | PLG4 | Socialize LDAP plugin RFC (`docs/rfcs/authority-plugin-ldap.md`) and capture guild feedback. | ✅ Guild review sign-off recorded; ✅ Follow-up issues filed in module boards. | | ||||||
|  |  | ||||||
|  | > Update statuses to DOING/DONE/BLOCKED as you make progress. Always run `dotnet test` for touched projects before marking DONE. | ||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | using System.Diagnostics; | ||||||
| using System.Security.Claims; | using System.Security.Claims; | ||||||
| using Microsoft.Extensions.Configuration; | using Microsoft.Extensions.Configuration; | ||||||
| using Microsoft.Extensions.Logging.Abstractions; | using Microsoft.Extensions.Logging.Abstractions; | ||||||
| @@ -18,6 +19,8 @@ namespace StellaOps.Authority.Tests.OpenIddict; | |||||||
|  |  | ||||||
| public class ClientCredentialsHandlersTests | public class ClientCredentialsHandlersTests | ||||||
| { | { | ||||||
|  |     private static readonly ActivitySource TestActivitySource = new("StellaOps.Authority.Tests"); | ||||||
|  |  | ||||||
|     [Fact] |     [Fact] | ||||||
|     public async Task ValidateClientCredentials_Rejects_WhenScopeNotAllowed() |     public async Task ValidateClientCredentials_Rejects_WhenScopeNotAllowed() | ||||||
|     { |     { | ||||||
| @@ -27,7 +30,7 @@ public class ClientCredentialsHandlersTests | |||||||
|             allowedScopes: "jobs:read"); |             allowedScopes: "jobs:read"); | ||||||
|  |  | ||||||
|         var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); |         var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); | ||||||
|         var handler = new ValidateClientCredentialsHandler(new TestClientStore(clientDocument), registry); |         var handler = new ValidateClientCredentialsHandler(new TestClientStore(clientDocument), registry, TestActivitySource, NullLogger<ValidateClientCredentialsHandler>.Instance); | ||||||
|  |  | ||||||
|         var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:write"); |         var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:write"); | ||||||
|         var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); |         var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); | ||||||
| @@ -48,7 +51,7 @@ public class ClientCredentialsHandlersTests | |||||||
|             allowedScopes: "jobs:read jobs:trigger"); |             allowedScopes: "jobs:read jobs:trigger"); | ||||||
|  |  | ||||||
|         var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); |         var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); | ||||||
|         var handler = new ValidateClientCredentialsHandler(new TestClientStore(clientDocument), registry); |         var handler = new ValidateClientCredentialsHandler(new TestClientStore(clientDocument), registry, TestActivitySource, NullLogger<ValidateClientCredentialsHandler>.Instance); | ||||||
|  |  | ||||||
|         var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); |         var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read"); | ||||||
|         var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); |         var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); | ||||||
| @@ -75,7 +78,7 @@ public class ClientCredentialsHandlersTests | |||||||
|         var descriptor = CreateDescriptor(clientDocument); |         var descriptor = CreateDescriptor(clientDocument); | ||||||
|         var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: descriptor); |         var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: descriptor); | ||||||
|         var tokenStore = new TestTokenStore(); |         var tokenStore = new TestTokenStore(); | ||||||
|         var handler = new HandleClientCredentialsHandler(registry, tokenStore, TimeProvider.System); |         var handler = new HandleClientCredentialsHandler(registry, tokenStore, TimeProvider.System, TestActivitySource, NullLogger<HandleClientCredentialsHandler>.Instance); | ||||||
|  |  | ||||||
|         var transaction = CreateTokenTransaction(clientDocument.ClientId, secret: null, scope: "jobs:trigger"); |         var transaction = CreateTokenTransaction(clientDocument.ClientId, secret: null, scope: "jobs:trigger"); | ||||||
|         transaction.Options.AccessTokenLifetime = TimeSpan.FromMinutes(30); |         transaction.Options.AccessTokenLifetime = TimeSpan.FromMinutes(30); | ||||||
| @@ -106,6 +109,8 @@ public class ClientCredentialsHandlersTests | |||||||
|  |  | ||||||
| public class TokenValidationHandlersTests | public class TokenValidationHandlersTests | ||||||
| { | { | ||||||
|  |     private static readonly ActivitySource TestActivitySource = new("StellaOps.Authority.Tests.TokenValidation"); | ||||||
|  |  | ||||||
|     [Fact] |     [Fact] | ||||||
|     public async Task ValidateAccessTokenHandler_Rejects_WhenTokenRevoked() |     public async Task ValidateAccessTokenHandler_Rejects_WhenTokenRevoked() | ||||||
|     { |     { | ||||||
| @@ -121,7 +126,9 @@ public class TokenValidationHandlersTests | |||||||
|             tokenStore, |             tokenStore, | ||||||
|             new TestClientStore(CreateClient()), |             new TestClientStore(CreateClient()), | ||||||
|             CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(CreateClient())), |             CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(CreateClient())), | ||||||
|             TimeProvider.System); |             TimeProvider.System, | ||||||
|  |             TestActivitySource, | ||||||
|  |             NullLogger<ValidateAccessTokenHandler>.Instance); | ||||||
|  |  | ||||||
|         var transaction = new OpenIddictServerTransaction |         var transaction = new OpenIddictServerTransaction | ||||||
|         { |         { | ||||||
| @@ -161,7 +168,9 @@ public class TokenValidationHandlersTests | |||||||
|             new TestTokenStore(), |             new TestTokenStore(), | ||||||
|             new TestClientStore(clientDocument), |             new TestClientStore(clientDocument), | ||||||
|             registry, |             registry, | ||||||
|             TimeProvider.System); |             TimeProvider.System, | ||||||
|  |             TestActivitySource, | ||||||
|  |             NullLogger<ValidateAccessTokenHandler>.Instance); | ||||||
|  |  | ||||||
|         var transaction = new OpenIddictServerTransaction |         var transaction = new OpenIddictServerTransaction | ||||||
|         { |         { | ||||||
|   | |||||||
| @@ -0,0 +1,208 @@ | |||||||
|  | using System.Collections.Generic; | ||||||
|  | using System.Diagnostics; | ||||||
|  | using System.Security.Claims; | ||||||
|  | using Microsoft.Extensions.DependencyInjection; | ||||||
|  | using Microsoft.Extensions.Logging.Abstractions; | ||||||
|  | using Microsoft.Extensions.Time.Testing; | ||||||
|  | using MongoDB.Driver; | ||||||
|  | using OpenIddict.Abstractions; | ||||||
|  | using OpenIddict.Server; | ||||||
|  | using StellaOps.Authority; | ||||||
|  | using StellaOps.Authority.OpenIddict; | ||||||
|  | using StellaOps.Authority.OpenIddict.Handlers; | ||||||
|  | using StellaOps.Authority.Plugins.Abstractions; | ||||||
|  | using StellaOps.Authority.Storage.Mongo; | ||||||
|  | using StellaOps.Authority.Storage.Mongo.Documents; | ||||||
|  | using StellaOps.Authority.Storage.Mongo.Extensions; | ||||||
|  | using StellaOps.Authority.Storage.Mongo.Initialization; | ||||||
|  | using StellaOps.Authority.Storage.Mongo.Stores; | ||||||
|  | using StellaOps.Feedser.Testing; | ||||||
|  | using Xunit; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Authority.Tests.OpenIddict; | ||||||
|  |  | ||||||
|  | [Collection("mongo-fixture")] | ||||||
|  | public sealed class TokenPersistenceIntegrationTests | ||||||
|  | { | ||||||
|  |     private static readonly ActivitySource TestActivitySource = new("StellaOps.Authority.Tests.Persistence"); | ||||||
|  |     private readonly MongoIntegrationFixture fixture; | ||||||
|  |  | ||||||
|  |     public TokenPersistenceIntegrationTests(MongoIntegrationFixture fixture) | ||||||
|  |         => this.fixture = fixture ?? throw new ArgumentNullException(nameof(fixture)); | ||||||
|  |  | ||||||
|  |     [Fact] | ||||||
|  |     public async Task HandleClientCredentials_PersistsTokenInMongo() | ||||||
|  |     { | ||||||
|  |         await ResetCollectionsAsync(); | ||||||
|  |  | ||||||
|  |         var issuedAt = new DateTimeOffset(2025, 10, 10, 12, 0, 0, TimeSpan.Zero); | ||||||
|  |         var clock = new FakeTimeProvider(issuedAt); | ||||||
|  |  | ||||||
|  |         await using var provider = await BuildMongoProviderAsync(clock); | ||||||
|  |  | ||||||
|  |         var clientStore = provider.GetRequiredService<IAuthorityClientStore>(); | ||||||
|  |         var tokenStore = provider.GetRequiredService<IAuthorityTokenStore>(); | ||||||
|  |  | ||||||
|  |         var clientDocument = TestHelpers.CreateClient( | ||||||
|  |             secret: "s3cr3t!", | ||||||
|  |             allowedGrantTypes: "client_credentials", | ||||||
|  |             allowedScopes: "jobs:trigger jobs:read"); | ||||||
|  |  | ||||||
|  |         await clientStore.UpsertAsync(clientDocument, CancellationToken.None); | ||||||
|  |  | ||||||
|  |         var registry = TestHelpers.CreateRegistry( | ||||||
|  |             withClientProvisioning: true, | ||||||
|  |             clientDescriptor: TestHelpers.CreateDescriptor(clientDocument)); | ||||||
|  |  | ||||||
|  |         var validateHandler = new ValidateClientCredentialsHandler(clientStore, registry, TestActivitySource, NullLogger<ValidateClientCredentialsHandler>.Instance); | ||||||
|  |         var handleHandler = new HandleClientCredentialsHandler(registry, tokenStore, clock, TestActivitySource, NullLogger<HandleClientCredentialsHandler>.Instance); | ||||||
|  |  | ||||||
|  |         var transaction = TestHelpers.CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:trigger"); | ||||||
|  |         transaction.Options.AccessTokenLifetime = TimeSpan.FromMinutes(15); | ||||||
|  |  | ||||||
|  |         var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); | ||||||
|  |         await validateHandler.HandleAsync(validateContext); | ||||||
|  |         Assert.False(validateContext.IsRejected); | ||||||
|  |  | ||||||
|  |         var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction); | ||||||
|  |         await handleHandler.HandleAsync(handleContext); | ||||||
|  |  | ||||||
|  |         Assert.True(handleContext.IsRequestHandled); | ||||||
|  |         var principal = Assert.IsType<ClaimsPrincipal>(handleContext.Principal); | ||||||
|  |         var tokenId = principal.GetClaim(OpenIddictConstants.Claims.JwtId); | ||||||
|  |         Assert.False(string.IsNullOrWhiteSpace(tokenId)); | ||||||
|  |  | ||||||
|  |         var stored = await tokenStore.FindByTokenIdAsync(tokenId!, CancellationToken.None); | ||||||
|  |         Assert.NotNull(stored); | ||||||
|  |         Assert.Equal(clientDocument.ClientId, stored!.ClientId); | ||||||
|  |         Assert.Equal(OpenIddictConstants.TokenTypeHints.AccessToken, stored.Type); | ||||||
|  |         Assert.Equal("valid", stored.Status); | ||||||
|  |         Assert.Equal(issuedAt, stored.CreatedAt); | ||||||
|  |         Assert.Equal(issuedAt.AddMinutes(15), stored.ExpiresAt); | ||||||
|  |         Assert.Equal(new[] { "jobs:trigger" }, stored.Scope); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [Fact] | ||||||
|  |     public async Task ValidateAccessTokenHandler_RejectsRevokedRefreshTokenPersistedInMongo() | ||||||
|  |     { | ||||||
|  |         await ResetCollectionsAsync(); | ||||||
|  |  | ||||||
|  |         var now = new DateTimeOffset(2025, 10, 10, 14, 0, 0, TimeSpan.Zero); | ||||||
|  |         var clock = new FakeTimeProvider(now); | ||||||
|  |  | ||||||
|  |         await using var provider = await BuildMongoProviderAsync(clock); | ||||||
|  |  | ||||||
|  |         var clientStore = provider.GetRequiredService<IAuthorityClientStore>(); | ||||||
|  |         var tokenStore = provider.GetRequiredService<IAuthorityTokenStore>(); | ||||||
|  |  | ||||||
|  |         var clientDocument = TestHelpers.CreateClient( | ||||||
|  |             secret: null, | ||||||
|  |             clientType: "public", | ||||||
|  |             allowedGrantTypes: "password refresh_token", | ||||||
|  |             allowedScopes: "openid profile jobs:read"); | ||||||
|  |  | ||||||
|  |         await clientStore.UpsertAsync(clientDocument, CancellationToken.None); | ||||||
|  |  | ||||||
|  |         var descriptor = TestHelpers.CreateDescriptor(clientDocument); | ||||||
|  |         var userDescriptor = new AuthorityUserDescriptor("subject-1", "alice", displayName: "Alice", requiresPasswordReset: false); | ||||||
|  |  | ||||||
|  |         var plugin = TestHelpers.CreatePlugin( | ||||||
|  |             name: clientDocument.Plugin ?? "standard", | ||||||
|  |             supportsClientProvisioning: true, | ||||||
|  |             descriptor, | ||||||
|  |             userDescriptor); | ||||||
|  |  | ||||||
|  |         var registry = new AuthorityIdentityProviderRegistry( | ||||||
|  |             new[] { plugin }, | ||||||
|  |             NullLogger<AuthorityIdentityProviderRegistry>.Instance); | ||||||
|  |  | ||||||
|  |         const string revokedTokenId = "refresh-token-1"; | ||||||
|  |         var refreshToken = new AuthorityTokenDocument | ||||||
|  |         { | ||||||
|  |             TokenId = revokedTokenId, | ||||||
|  |             Type = OpenIddictConstants.TokenTypeHints.RefreshToken, | ||||||
|  |             SubjectId = userDescriptor.SubjectId, | ||||||
|  |             ClientId = clientDocument.ClientId, | ||||||
|  |             Scope = new List<string> { "openid", "profile" }, | ||||||
|  |             Status = "valid", | ||||||
|  |             CreatedAt = now.AddMinutes(-5), | ||||||
|  |             ExpiresAt = now.AddHours(4), | ||||||
|  |             ReferenceId = "refresh-reference-1" | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         await tokenStore.InsertAsync(refreshToken, CancellationToken.None); | ||||||
|  |  | ||||||
|  |         var revokedAt = now.AddMinutes(1); | ||||||
|  |         await tokenStore.UpdateStatusAsync(revokedTokenId, "revoked", revokedAt, CancellationToken.None); | ||||||
|  |  | ||||||
|  |         var handler = new ValidateAccessTokenHandler( | ||||||
|  |             tokenStore, | ||||||
|  |             clientStore, | ||||||
|  |             registry, | ||||||
|  |             clock, | ||||||
|  |             TestActivitySource, | ||||||
|  |             NullLogger<ValidateAccessTokenHandler>.Instance); | ||||||
|  |  | ||||||
|  |         var transaction = new OpenIddictServerTransaction | ||||||
|  |         { | ||||||
|  |             EndpointType = OpenIddictServerEndpointType.Token, | ||||||
|  |             Options = new OpenIddictServerOptions(), | ||||||
|  |             Request = new OpenIddictRequest | ||||||
|  |             { | ||||||
|  |                 GrantType = OpenIddictConstants.GrantTypes.RefreshToken | ||||||
|  |             } | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         var principal = TestHelpers.CreatePrincipal( | ||||||
|  |             clientDocument.ClientId, | ||||||
|  |             revokedTokenId, | ||||||
|  |             plugin.Name, | ||||||
|  |             userDescriptor.SubjectId); | ||||||
|  |  | ||||||
|  |         var context = new OpenIddictServerEvents.ValidateTokenContext(transaction) | ||||||
|  |         { | ||||||
|  |             Principal = principal, | ||||||
|  |             TokenId = revokedTokenId | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         await handler.HandleAsync(context); | ||||||
|  |  | ||||||
|  |         Assert.True(context.IsRejected); | ||||||
|  |         Assert.Equal(OpenIddictConstants.Errors.InvalidToken, context.Error); | ||||||
|  |  | ||||||
|  |         var stored = await tokenStore.FindByTokenIdAsync(revokedTokenId, CancellationToken.None); | ||||||
|  |         Assert.NotNull(stored); | ||||||
|  |         Assert.Equal("revoked", stored!.Status); | ||||||
|  |        Assert.Equal(revokedAt, stored.RevokedAt); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private async Task ResetCollectionsAsync() | ||||||
|  |     { | ||||||
|  |         var tokens = fixture.Database.GetCollection<AuthorityTokenDocument>(AuthorityMongoDefaults.Collections.Tokens); | ||||||
|  |         await tokens.DeleteManyAsync(Builders<AuthorityTokenDocument>.Filter.Empty); | ||||||
|  |  | ||||||
|  |         var clients = fixture.Database.GetCollection<AuthorityClientDocument>(AuthorityMongoDefaults.Collections.Clients); | ||||||
|  |         await clients.DeleteManyAsync(Builders<AuthorityClientDocument>.Filter.Empty); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private async Task<ServiceProvider> BuildMongoProviderAsync(FakeTimeProvider clock) | ||||||
|  |     { | ||||||
|  |         var services = new ServiceCollection(); | ||||||
|  |         services.AddSingleton<TimeProvider>(clock); | ||||||
|  |         services.AddLogging(); | ||||||
|  |         services.AddAuthorityMongoStorage(options => | ||||||
|  |         { | ||||||
|  |             options.ConnectionString = fixture.Runner.ConnectionString; | ||||||
|  |             options.DatabaseName = fixture.Database.DatabaseNamespace.DatabaseName; | ||||||
|  |             options.CommandTimeout = TimeSpan.FromSeconds(5); | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         var provider = services.BuildServiceProvider(); | ||||||
|  |  | ||||||
|  |         var initializer = provider.GetRequiredService<AuthorityMongoInitializer>(); | ||||||
|  |         var database = provider.GetRequiredService<IMongoDatabase>(); | ||||||
|  |         await initializer.InitialiseAsync(database, CancellationToken.None); | ||||||
|  |  | ||||||
|  |         return provider; | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,59 @@ | |||||||
|  | using System; | ||||||
|  | using System.Net; | ||||||
|  | using Microsoft.AspNetCore.Http; | ||||||
|  | using StellaOps.Authority; | ||||||
|  | using StellaOps.Configuration; | ||||||
|  | using Xunit; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Authority.Tests.RateLimiting; | ||||||
|  |  | ||||||
|  | public class AuthorityRateLimiterTests | ||||||
|  | { | ||||||
|  |     [Fact] | ||||||
|  |     public async Task TokenLimiter_Throttles_WhenLimitExceeded() | ||||||
|  |     { | ||||||
|  |         var options = CreateOptions(); | ||||||
|  |         options.RateLimiting.Token.PermitLimit = 1; | ||||||
|  |         options.RateLimiting.Token.Window = TimeSpan.FromMinutes(1); | ||||||
|  |  | ||||||
|  |         using var limiter = AuthorityRateLimiter.CreateGlobalLimiter(options); | ||||||
|  |  | ||||||
|  |         var context = new DefaultHttpContext(); | ||||||
|  |         context.Request.Path = "/token"; | ||||||
|  |         context.Connection.RemoteIpAddress = IPAddress.Parse("203.0.113.10"); | ||||||
|  |  | ||||||
|  |         var first = await limiter.AcquireAsync(context); | ||||||
|  |         Assert.True(first.IsAcquired); | ||||||
|  |  | ||||||
|  |         var second = await limiter.AcquireAsync(context); | ||||||
|  |         Assert.False(second.IsAcquired); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [Fact] | ||||||
|  |     public async Task AuthorizeLimiter_Allows_WhenDisabled() | ||||||
|  |     { | ||||||
|  |         var options = CreateOptions(); | ||||||
|  |         options.RateLimiting.Authorize.Enabled = false; | ||||||
|  |  | ||||||
|  |         using var limiter = AuthorityRateLimiter.CreateGlobalLimiter(options); | ||||||
|  |  | ||||||
|  |         var context = new DefaultHttpContext(); | ||||||
|  |         context.Request.Path = "/authorize"; | ||||||
|  |         context.Connection.RemoteIpAddress = IPAddress.Parse("203.0.113.20"); | ||||||
|  |  | ||||||
|  |         var lease = await limiter.AcquireAsync(context); | ||||||
|  |         Assert.True(lease.IsAcquired); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static StellaOpsAuthorityOptions CreateOptions() | ||||||
|  |     { | ||||||
|  |         var options = new StellaOpsAuthorityOptions | ||||||
|  |         { | ||||||
|  |             Issuer = new Uri("https://authority.stella-ops.test"), | ||||||
|  |             SchemaVersion = 1 | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         options.Storage.ConnectionString = "mongodb://localhost/authority"; | ||||||
|  |         return options; | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -49,6 +49,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.ServerIntegr | |||||||
| EndProject | EndProject | ||||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Client.Tests", "StellaOps.Auth.Client.Tests\StellaOps.Auth.Client.Tests.csproj", "{2DB48E45-BEFE-40FC-8E7D-1697A8EB0749}" | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Client.Tests", "StellaOps.Auth.Client.Tests\StellaOps.Auth.Client.Tests.csproj", "{2DB48E45-BEFE-40FC-8E7D-1697A8EB0749}" | ||||||
| EndProject | EndProject | ||||||
|  | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "..\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{35D22E43-729A-4D43-A289-5A0E96BA0199}" | ||||||
|  | EndProject | ||||||
|  | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Tests", "..\StellaOps.Cryptography.Tests\StellaOps.Cryptography.Tests.csproj", "{84AEC0C8-EE60-4AB1-A59B-B8E7CCFC0A25}" | ||||||
|  | EndProject | ||||||
| Global | Global | ||||||
| 	GlobalSection(SolutionConfigurationPlatforms) = preSolution | 	GlobalSection(SolutionConfigurationPlatforms) = preSolution | ||||||
| 		Debug|Any CPU = Debug|Any CPU | 		Debug|Any CPU = Debug|Any CPU | ||||||
| @@ -335,6 +339,30 @@ Global | |||||||
| 		{2DB48E45-BEFE-40FC-8E7D-1697A8EB0749}.Release|x64.Build.0 = Release|Any CPU | 		{2DB48E45-BEFE-40FC-8E7D-1697A8EB0749}.Release|x64.Build.0 = Release|Any CPU | ||||||
| 		{2DB48E45-BEFE-40FC-8E7D-1697A8EB0749}.Release|x86.ActiveCfg = Release|Any CPU | 		{2DB48E45-BEFE-40FC-8E7D-1697A8EB0749}.Release|x86.ActiveCfg = Release|Any CPU | ||||||
| 		{2DB48E45-BEFE-40FC-8E7D-1697A8EB0749}.Release|x86.Build.0 = Release|Any CPU | 		{2DB48E45-BEFE-40FC-8E7D-1697A8EB0749}.Release|x86.Build.0 = Release|Any CPU | ||||||
|  | 		{35D22E43-729A-4D43-A289-5A0E96BA0199}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | ||||||
|  | 		{35D22E43-729A-4D43-A289-5A0E96BA0199}.Debug|Any CPU.Build.0 = Debug|Any CPU | ||||||
|  | 		{35D22E43-729A-4D43-A289-5A0E96BA0199}.Debug|x64.ActiveCfg = Debug|Any CPU | ||||||
|  | 		{35D22E43-729A-4D43-A289-5A0E96BA0199}.Debug|x64.Build.0 = Debug|Any CPU | ||||||
|  | 		{35D22E43-729A-4D43-A289-5A0E96BA0199}.Debug|x86.ActiveCfg = Debug|Any CPU | ||||||
|  | 		{35D22E43-729A-4D43-A289-5A0E96BA0199}.Debug|x86.Build.0 = Debug|Any CPU | ||||||
|  | 		{35D22E43-729A-4D43-A289-5A0E96BA0199}.Release|Any CPU.ActiveCfg = Release|Any CPU | ||||||
|  | 		{35D22E43-729A-4D43-A289-5A0E96BA0199}.Release|Any CPU.Build.0 = Release|Any CPU | ||||||
|  | 		{35D22E43-729A-4D43-A289-5A0E96BA0199}.Release|x64.ActiveCfg = Release|Any CPU | ||||||
|  | 		{35D22E43-729A-4D43-A289-5A0E96BA0199}.Release|x64.Build.0 = Release|Any CPU | ||||||
|  | 		{35D22E43-729A-4D43-A289-5A0E96BA0199}.Release|x86.ActiveCfg = Release|Any CPU | ||||||
|  | 		{35D22E43-729A-4D43-A289-5A0E96BA0199}.Release|x86.Build.0 = Release|Any CPU | ||||||
|  | 		{84AEC0C8-EE60-4AB1-A59B-B8E7CCFC0A25}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | ||||||
|  | 		{84AEC0C8-EE60-4AB1-A59B-B8E7CCFC0A25}.Debug|Any CPU.Build.0 = Debug|Any CPU | ||||||
|  | 		{84AEC0C8-EE60-4AB1-A59B-B8E7CCFC0A25}.Debug|x64.ActiveCfg = Debug|Any CPU | ||||||
|  | 		{84AEC0C8-EE60-4AB1-A59B-B8E7CCFC0A25}.Debug|x64.Build.0 = Debug|Any CPU | ||||||
|  | 		{84AEC0C8-EE60-4AB1-A59B-B8E7CCFC0A25}.Debug|x86.ActiveCfg = Debug|Any CPU | ||||||
|  | 		{84AEC0C8-EE60-4AB1-A59B-B8E7CCFC0A25}.Debug|x86.Build.0 = Debug|Any CPU | ||||||
|  | 		{84AEC0C8-EE60-4AB1-A59B-B8E7CCFC0A25}.Release|Any CPU.ActiveCfg = Release|Any CPU | ||||||
|  | 		{84AEC0C8-EE60-4AB1-A59B-B8E7CCFC0A25}.Release|Any CPU.Build.0 = Release|Any CPU | ||||||
|  | 		{84AEC0C8-EE60-4AB1-A59B-B8E7CCFC0A25}.Release|x64.ActiveCfg = Release|Any CPU | ||||||
|  | 		{84AEC0C8-EE60-4AB1-A59B-B8E7CCFC0A25}.Release|x64.Build.0 = Release|Any CPU | ||||||
|  | 		{84AEC0C8-EE60-4AB1-A59B-B8E7CCFC0A25}.Release|x86.ActiveCfg = Release|Any CPU | ||||||
|  | 		{84AEC0C8-EE60-4AB1-A59B-B8E7CCFC0A25}.Release|x86.Build.0 = Release|Any CPU | ||||||
| 	EndGlobalSection | 	EndGlobalSection | ||||||
| 	GlobalSection(SolutionProperties) = preSolution | 	GlobalSection(SolutionProperties) = preSolution | ||||||
| 		HideSolutionNode = FALSE | 		HideSolutionNode = FALSE | ||||||
|   | |||||||
| @@ -0,0 +1,103 @@ | |||||||
|  | using System; | ||||||
|  | using System.Globalization; | ||||||
|  | using System.Net; | ||||||
|  | using System.Threading.RateLimiting; | ||||||
|  | using Microsoft.AspNetCore.Http; | ||||||
|  | using Microsoft.AspNetCore.RateLimiting; | ||||||
|  | using Microsoft.Extensions.DependencyInjection; | ||||||
|  | using Microsoft.Extensions.Logging; | ||||||
|  | using StellaOps.Configuration; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Authority; | ||||||
|  |  | ||||||
|  | internal static class AuthorityRateLimiter | ||||||
|  | { | ||||||
|  |     internal const string TokenLimiterName = "authority-token"; | ||||||
|  |     internal const string AuthorizeLimiterName = "authority-authorize"; | ||||||
|  |  | ||||||
|  |     public static void Configure(RateLimiterOptions options, StellaOpsAuthorityOptions authorityOptions, ILogger? logger = null) | ||||||
|  |     { | ||||||
|  |         ArgumentNullException.ThrowIfNull(options); | ||||||
|  |         ArgumentNullException.ThrowIfNull(authorityOptions); | ||||||
|  |  | ||||||
|  |         options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; | ||||||
|  |         options.GlobalLimiter = CreateGlobalLimiter(authorityOptions); | ||||||
|  |         options.OnRejected = async (context, cancellationToken) => | ||||||
|  |         { | ||||||
|  |             var httpContext = context.HttpContext; | ||||||
|  |             var remoteIp = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; | ||||||
|  |             logger ??= httpContext.RequestServices.GetService(typeof(ILoggerFactory)) is ILoggerFactory loggerFactory | ||||||
|  |                 ? loggerFactory.CreateLogger("StellaOps.Authority.RateLimiting") | ||||||
|  |                 : null; | ||||||
|  |  | ||||||
|  |             logger?.LogWarning( | ||||||
|  |                 "Rate limit exceeded for path {Path} from {RemoteIp}.", | ||||||
|  |                 httpContext.Request.Path, | ||||||
|  |                 remoteIp); | ||||||
|  |  | ||||||
|  |             if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out TimeSpan retryAfter)) | ||||||
|  |             { | ||||||
|  |                 httpContext.Response.Headers["Retry-After"] = Math.Ceiling(retryAfter.TotalSeconds).ToString(CultureInfo.InvariantCulture); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             await ValueTask.CompletedTask; | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     public static PartitionedRateLimiter<HttpContext> CreateGlobalLimiter(StellaOpsAuthorityOptions authorityOptions) | ||||||
|  |     { | ||||||
|  |         ArgumentNullException.ThrowIfNull(authorityOptions); | ||||||
|  |  | ||||||
|  |         var tokenOptions = authorityOptions.RateLimiting.Token; | ||||||
|  |         var authorizeOptions = authorityOptions.RateLimiting.Authorize; | ||||||
|  |  | ||||||
|  |         return PartitionedRateLimiter.Create<HttpContext, string>(context => | ||||||
|  |         { | ||||||
|  |             var path = context.Request.Path; | ||||||
|  |  | ||||||
|  |             if (tokenOptions.Enabled && path.HasValue && path.Value!.Equals("/token", StringComparison.OrdinalIgnoreCase)) | ||||||
|  |             { | ||||||
|  |                 return RateLimitPartition.GetFixedWindowLimiter( | ||||||
|  |                     $"{TokenLimiterName}:{ResolvePartitionKey(context)}", | ||||||
|  |                     _ => CreateFixedWindowOptions(tokenOptions)); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (authorizeOptions.Enabled && path.HasValue && path.Value!.Equals("/authorize", StringComparison.OrdinalIgnoreCase)) | ||||||
|  |             { | ||||||
|  |                 return RateLimitPartition.GetFixedWindowLimiter( | ||||||
|  |                     $"{AuthorizeLimiterName}:{ResolvePartitionKey(context)}", | ||||||
|  |                     _ => CreateFixedWindowOptions(authorizeOptions)); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             return RateLimitPartition.GetNoLimiter(ResolvePartitionKey(context)); | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static FixedWindowRateLimiterOptions CreateFixedWindowOptions(AuthorityEndpointRateLimitOptions options) | ||||||
|  |     { | ||||||
|  |         return new FixedWindowRateLimiterOptions | ||||||
|  |         { | ||||||
|  |             PermitLimit = options.PermitLimit, | ||||||
|  |             QueueLimit = options.QueueLimit, | ||||||
|  |             QueueProcessingOrder = options.QueueProcessingOrder, | ||||||
|  |             Window = options.Window, | ||||||
|  |             AutoReplenishment = true | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string ResolvePartitionKey(HttpContext context) | ||||||
|  |     { | ||||||
|  |         var remoteIp = context.Connection.RemoteIpAddress; | ||||||
|  |         if (remoteIp is null) | ||||||
|  |         { | ||||||
|  |             return "unknown"; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (remoteIp.Equals(IPAddress.IPv6None) || remoteIp.Equals(IPAddress.Any) || remoteIp.Equals(IPAddress.IPv6Any)) | ||||||
|  |         { | ||||||
|  |             return "unknown"; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return remoteIp.ToString(); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,6 +1,8 @@ | |||||||
|  | using System.Diagnostics; | ||||||
| using System.Linq; | using System.Linq; | ||||||
| using System.Security.Claims; | using System.Security.Claims; | ||||||
| using System.Security.Cryptography; | using System.Security.Cryptography; | ||||||
|  | using Microsoft.Extensions.Logging; | ||||||
| using OpenIddict.Abstractions; | using OpenIddict.Abstractions; | ||||||
| using OpenIddict.Extensions; | using OpenIddict.Extensions; | ||||||
| using OpenIddict.Server; | using OpenIddict.Server; | ||||||
| @@ -17,13 +19,19 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle | |||||||
| { | { | ||||||
|     private readonly IAuthorityClientStore clientStore; |     private readonly IAuthorityClientStore clientStore; | ||||||
|     private readonly IAuthorityIdentityProviderRegistry registry; |     private readonly IAuthorityIdentityProviderRegistry registry; | ||||||
|  |     private readonly ActivitySource activitySource; | ||||||
|  |     private readonly ILogger<ValidateClientCredentialsHandler> logger; | ||||||
|  |  | ||||||
|     public ValidateClientCredentialsHandler( |     public ValidateClientCredentialsHandler( | ||||||
|         IAuthorityClientStore clientStore, |         IAuthorityClientStore clientStore, | ||||||
|         IAuthorityIdentityProviderRegistry registry) |         IAuthorityIdentityProviderRegistry registry, | ||||||
|  |         ActivitySource activitySource, | ||||||
|  |         ILogger<ValidateClientCredentialsHandler> logger) | ||||||
|     { |     { | ||||||
|         this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore)); |         this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore)); | ||||||
|         this.registry = registry ?? throw new ArgumentNullException(nameof(registry)); |         this.registry = registry ?? throw new ArgumentNullException(nameof(registry)); | ||||||
|  |         this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource)); | ||||||
|  |         this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context) |     public async ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context) | ||||||
| @@ -35,9 +43,15 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle | |||||||
|             return; |             return; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         using var activity = activitySource.StartActivity("authority.token.validate_client_credentials", ActivityKind.Internal); | ||||||
|  |         activity?.SetTag("authority.endpoint", "/token"); | ||||||
|  |         activity?.SetTag("authority.grant_type", OpenIddictConstants.GrantTypes.ClientCredentials); | ||||||
|  |         activity?.SetTag("authority.client_id", context.ClientId ?? string.Empty); | ||||||
|  |  | ||||||
|         if (string.IsNullOrWhiteSpace(context.ClientId)) |         if (string.IsNullOrWhiteSpace(context.ClientId)) | ||||||
|         { |         { | ||||||
|             context.Reject(OpenIddictConstants.Errors.InvalidClient, "Client identifier is required."); |             context.Reject(OpenIddictConstants.Errors.InvalidClient, "Client identifier is required."); | ||||||
|  |             logger.LogWarning("Client credentials validation failed: missing client identifier."); | ||||||
|             return; |             return; | ||||||
|         } |         } | ||||||
|  |  | ||||||
| @@ -45,6 +59,7 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle | |||||||
|         if (document is null || document.Disabled) |         if (document is null || document.Disabled) | ||||||
|         { |         { | ||||||
|             context.Reject(OpenIddictConstants.Errors.InvalidClient, "Unknown or disabled client identifier."); |             context.Reject(OpenIddictConstants.Errors.InvalidClient, "Unknown or disabled client identifier."); | ||||||
|  |             logger.LogWarning("Client credentials validation failed for {ClientId}: client not found or disabled.", context.ClientId); | ||||||
|             return; |             return; | ||||||
|         } |         } | ||||||
|  |  | ||||||
| @@ -54,12 +69,14 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle | |||||||
|             if (!registry.TryGet(document.Plugin, out provider)) |             if (!registry.TryGet(document.Plugin, out provider)) | ||||||
|             { |             { | ||||||
|                 context.Reject(OpenIddictConstants.Errors.InvalidClient, "Configured identity provider is unavailable."); |                 context.Reject(OpenIddictConstants.Errors.InvalidClient, "Configured identity provider is unavailable."); | ||||||
|  |                 logger.LogWarning("Client credentials validation failed for {ClientId}: provider {Provider} unavailable.", context.ClientId, document.Plugin); | ||||||
|                 return; |                 return; | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             if (!provider.Capabilities.SupportsClientProvisioning || provider.ClientProvisioning is null) |             if (!provider.Capabilities.SupportsClientProvisioning || provider.ClientProvisioning is null) | ||||||
|             { |             { | ||||||
|                 context.Reject(OpenIddictConstants.Errors.UnauthorizedClient, "Associated identity provider does not support client provisioning."); |                 context.Reject(OpenIddictConstants.Errors.UnauthorizedClient, "Associated identity provider does not support client provisioning."); | ||||||
|  |                 logger.LogWarning("Client credentials validation failed for {ClientId}: provider {Provider} lacks client provisioning capabilities.", context.ClientId, provider.Name); | ||||||
|                 return; |                 return; | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
| @@ -69,6 +86,7 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle | |||||||
|             !allowedGrantTypes.Any(static grant => string.Equals(grant, OpenIddictConstants.GrantTypes.ClientCredentials, StringComparison.Ordinal))) |             !allowedGrantTypes.Any(static grant => string.Equals(grant, OpenIddictConstants.GrantTypes.ClientCredentials, StringComparison.Ordinal))) | ||||||
|         { |         { | ||||||
|             context.Reject(OpenIddictConstants.Errors.UnauthorizedClient, "Client credentials grant is not permitted for this client."); |             context.Reject(OpenIddictConstants.Errors.UnauthorizedClient, "Client credentials grant is not permitted for this client."); | ||||||
|  |             logger.LogWarning("Client credentials validation failed for {ClientId}: grant type not allowed.", document.ClientId); | ||||||
|             return; |             return; | ||||||
|         } |         } | ||||||
|  |  | ||||||
| @@ -78,6 +96,7 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle | |||||||
|             if (string.IsNullOrWhiteSpace(document.SecretHash)) |             if (string.IsNullOrWhiteSpace(document.SecretHash)) | ||||||
|             { |             { | ||||||
|                 context.Reject(OpenIddictConstants.Errors.InvalidClient, "Client secret is not configured."); |                 context.Reject(OpenIddictConstants.Errors.InvalidClient, "Client secret is not configured."); | ||||||
|  |                 logger.LogWarning("Client credentials validation failed for {ClientId}: secret not configured.", document.ClientId); | ||||||
|                 return; |                 return; | ||||||
|             } |             } | ||||||
|  |  | ||||||
| @@ -85,6 +104,7 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle | |||||||
|                 !ClientCredentialHandlerHelpers.VerifySecret(context.ClientSecret, document.SecretHash)) |                 !ClientCredentialHandlerHelpers.VerifySecret(context.ClientSecret, document.SecretHash)) | ||||||
|             { |             { | ||||||
|                 context.Reject(OpenIddictConstants.Errors.InvalidClient, "Invalid client credentials."); |                 context.Reject(OpenIddictConstants.Errors.InvalidClient, "Invalid client credentials."); | ||||||
|  |                 logger.LogWarning("Client credentials validation failed for {ClientId}: secret verification failed.", document.ClientId); | ||||||
|                 return; |                 return; | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
| @@ -92,6 +112,7 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle | |||||||
|                  !ClientCredentialHandlerHelpers.VerifySecret(context.ClientSecret, document.SecretHash)) |                  !ClientCredentialHandlerHelpers.VerifySecret(context.ClientSecret, document.SecretHash)) | ||||||
|         { |         { | ||||||
|             context.Reject(OpenIddictConstants.Errors.InvalidClient, "Invalid client credentials."); |             context.Reject(OpenIddictConstants.Errors.InvalidClient, "Invalid client credentials."); | ||||||
|  |             logger.LogWarning("Client credentials validation failed for {ClientId}: secret verification failed.", document.ClientId); | ||||||
|             return; |             return; | ||||||
|         } |         } | ||||||
|  |  | ||||||
| @@ -103,6 +124,7 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle | |||||||
|         if (resolvedScopes.InvalidScope is not null) |         if (resolvedScopes.InvalidScope is not null) | ||||||
|         { |         { | ||||||
|             context.Reject(OpenIddictConstants.Errors.InvalidScope, $"Scope '{resolvedScopes.InvalidScope}' is not allowed for this client."); |             context.Reject(OpenIddictConstants.Errors.InvalidScope, $"Scope '{resolvedScopes.InvalidScope}' is not allowed for this client."); | ||||||
|  |             logger.LogWarning("Client credentials validation failed for {ClientId}: scope {Scope} not permitted.", document.ClientId, resolvedScopes.InvalidScope); | ||||||
|             return; |             return; | ||||||
|         } |         } | ||||||
|  |  | ||||||
| @@ -110,9 +132,11 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle | |||||||
|         if (provider is not null) |         if (provider is not null) | ||||||
|         { |         { | ||||||
|             context.Transaction.Properties[AuthorityOpenIddictConstants.ClientProviderTransactionProperty] = provider.Name; |             context.Transaction.Properties[AuthorityOpenIddictConstants.ClientProviderTransactionProperty] = provider.Name; | ||||||
|  |             activity?.SetTag("authority.identity_provider", provider.Name); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty] = resolvedScopes.Scopes; |         context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty] = resolvedScopes.Scopes; | ||||||
|  |         logger.LogInformation("Client credentials validated for {ClientId}.", document.ClientId); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -121,15 +145,21 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler< | |||||||
|     private readonly IAuthorityIdentityProviderRegistry registry; |     private readonly IAuthorityIdentityProviderRegistry registry; | ||||||
|     private readonly IAuthorityTokenStore tokenStore; |     private readonly IAuthorityTokenStore tokenStore; | ||||||
|     private readonly TimeProvider clock; |     private readonly TimeProvider clock; | ||||||
|  |     private readonly ActivitySource activitySource; | ||||||
|  |     private readonly ILogger<HandleClientCredentialsHandler> logger; | ||||||
|  |  | ||||||
|     public HandleClientCredentialsHandler( |     public HandleClientCredentialsHandler( | ||||||
|         IAuthorityIdentityProviderRegistry registry, |         IAuthorityIdentityProviderRegistry registry, | ||||||
|         IAuthorityTokenStore tokenStore, |         IAuthorityTokenStore tokenStore, | ||||||
|         TimeProvider clock) |         TimeProvider clock, | ||||||
|  |         ActivitySource activitySource, | ||||||
|  |         ILogger<HandleClientCredentialsHandler> logger) | ||||||
|     { |     { | ||||||
|         this.registry = registry ?? throw new ArgumentNullException(nameof(registry)); |         this.registry = registry ?? throw new ArgumentNullException(nameof(registry)); | ||||||
|         this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore)); |         this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore)); | ||||||
|         this.clock = clock ?? throw new ArgumentNullException(nameof(clock)); |         this.clock = clock ?? throw new ArgumentNullException(nameof(clock)); | ||||||
|  |         this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource)); | ||||||
|  |         this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async ValueTask HandleAsync(OpenIddictServerEvents.HandleTokenRequestContext context) |     public async ValueTask HandleAsync(OpenIddictServerEvents.HandleTokenRequestContext context) | ||||||
| @@ -141,6 +171,8 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler< | |||||||
|             return; |             return; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         using var activity = activitySource.StartActivity("authority.token.handle_client_credentials", ActivityKind.Internal); | ||||||
|  |  | ||||||
|         if (!context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientTransactionProperty, out var value) || |         if (!context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientTransactionProperty, out var value) || | ||||||
|             value is not AuthorityClientDocument document) |             value is not AuthorityClientDocument document) | ||||||
|         { |         { | ||||||
| @@ -151,6 +183,9 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler< | |||||||
|         var identity = new ClaimsIdentity(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); |         var identity = new ClaimsIdentity(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); | ||||||
|         identity.AddClaim(new Claim(OpenIddictConstants.Claims.Subject, document.ClientId)); |         identity.AddClaim(new Claim(OpenIddictConstants.Claims.Subject, document.ClientId)); | ||||||
|         identity.AddClaim(new Claim(OpenIddictConstants.Claims.ClientId, document.ClientId)); |         identity.AddClaim(new Claim(OpenIddictConstants.Claims.ClientId, document.ClientId)); | ||||||
|  |         activity?.SetTag("authority.client_id", document.ClientId); | ||||||
|  |         activity?.SetTag("authority.endpoint", "/token"); | ||||||
|  |         activity?.SetTag("authority.grant_type", OpenIddictConstants.GrantTypes.ClientCredentials); | ||||||
|  |  | ||||||
|         var tokenId = identity.GetClaim(OpenIddictConstants.Claims.JwtId); |         var tokenId = identity.GetClaim(OpenIddictConstants.Claims.JwtId); | ||||||
|         if (string.IsNullOrEmpty(tokenId)) |         if (string.IsNullOrEmpty(tokenId)) | ||||||
| @@ -171,6 +206,7 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler< | |||||||
|         var (provider, descriptor) = await ResolveProviderAsync(context, document).ConfigureAwait(false); |         var (provider, descriptor) = await ResolveProviderAsync(context, document).ConfigureAwait(false); | ||||||
|         if (context.IsRejected) |         if (context.IsRejected) | ||||||
|         { |         { | ||||||
|  |             logger.LogWarning("Client credentials request rejected for {ClientId} during provider resolution.", document.ClientId); | ||||||
|             return; |             return; | ||||||
|         } |         } | ||||||
|  |  | ||||||
| @@ -179,11 +215,13 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler< | |||||||
|             if (!string.IsNullOrWhiteSpace(document.Plugin)) |             if (!string.IsNullOrWhiteSpace(document.Plugin)) | ||||||
|             { |             { | ||||||
|                 identity.SetClaim(StellaOpsClaimTypes.IdentityProvider, document.Plugin); |                 identity.SetClaim(StellaOpsClaimTypes.IdentityProvider, document.Plugin); | ||||||
|  |                 activity?.SetTag("authority.identity_provider", document.Plugin); | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|         else |         else | ||||||
|         { |         { | ||||||
|             identity.SetClaim(StellaOpsClaimTypes.IdentityProvider, provider.Name); |             identity.SetClaim(StellaOpsClaimTypes.IdentityProvider, provider.Name); | ||||||
|  |             activity?.SetTag("authority.identity_provider", provider.Name); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         var principal = new ClaimsPrincipal(identity); |         var principal = new ClaimsPrincipal(identity); | ||||||
| @@ -208,10 +246,11 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler< | |||||||
|             await provider.ClaimsEnricher.EnrichAsync(identity, enrichmentContext, context.CancellationToken).ConfigureAwait(false); |             await provider.ClaimsEnricher.EnrichAsync(identity, enrichmentContext, context.CancellationToken).ConfigureAwait(false); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         await PersistTokenAsync(context, document, tokenId, grantedScopes).ConfigureAwait(false); |         await PersistTokenAsync(context, document, tokenId, grantedScopes, activity).ConfigureAwait(false); | ||||||
|  |  | ||||||
|         context.Principal = principal; |         context.Principal = principal; | ||||||
|         context.HandleRequest(); |         context.HandleRequest(); | ||||||
|  |         logger.LogInformation("Issued client credentials access token for {ClientId} with scopes {Scopes}.", document.ClientId, grantedScopes); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private async ValueTask<(IIdentityProviderPlugin? Provider, AuthorityClientDescriptor? Descriptor)> ResolveProviderAsync( |     private async ValueTask<(IIdentityProviderPlugin? Provider, AuthorityClientDescriptor? Descriptor)> ResolveProviderAsync( | ||||||
| @@ -255,7 +294,8 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler< | |||||||
|         OpenIddictServerEvents.HandleTokenRequestContext context, |         OpenIddictServerEvents.HandleTokenRequestContext context, | ||||||
|         AuthorityClientDocument document, |         AuthorityClientDocument document, | ||||||
|         string tokenId, |         string tokenId, | ||||||
|         IReadOnlyCollection<string> scopes) |         IReadOnlyCollection<string> scopes, | ||||||
|  |         Activity? activity) | ||||||
|     { |     { | ||||||
|         if (context.IsRejected) |         if (context.IsRejected) | ||||||
|         { |         { | ||||||
| @@ -282,6 +322,7 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler< | |||||||
|  |  | ||||||
|         await tokenStore.InsertAsync(record, context.CancellationToken).ConfigureAwait(false); |         await tokenStore.InsertAsync(record, context.CancellationToken).ConfigureAwait(false); | ||||||
|         context.Transaction.Properties[AuthorityOpenIddictConstants.TokenTransactionProperty] = record; |         context.Transaction.Properties[AuthorityOpenIddictConstants.TokenTransactionProperty] = record; | ||||||
|  |         activity?.SetTag("authority.token_id", tokenId); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,4 +1,6 @@ | |||||||
|  | using System.Diagnostics; | ||||||
| using System.Security.Claims; | using System.Security.Claims; | ||||||
|  | using Microsoft.Extensions.Logging; | ||||||
| using OpenIddict.Abstractions; | using OpenIddict.Abstractions; | ||||||
| using OpenIddict.Extensions; | using OpenIddict.Extensions; | ||||||
| using OpenIddict.Server; | using OpenIddict.Server; | ||||||
| @@ -11,10 +13,17 @@ namespace StellaOps.Authority.OpenIddict.Handlers; | |||||||
| internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<OpenIddictServerEvents.ValidateTokenRequestContext> | internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<OpenIddictServerEvents.ValidateTokenRequestContext> | ||||||
| { | { | ||||||
|     private readonly IAuthorityIdentityProviderRegistry registry; |     private readonly IAuthorityIdentityProviderRegistry registry; | ||||||
|  |     private readonly ActivitySource activitySource; | ||||||
|  |     private readonly ILogger<ValidatePasswordGrantHandler> logger; | ||||||
|  |  | ||||||
|     public ValidatePasswordGrantHandler(IAuthorityIdentityProviderRegistry registry) |     public ValidatePasswordGrantHandler( | ||||||
|  |         IAuthorityIdentityProviderRegistry registry, | ||||||
|  |         ActivitySource activitySource, | ||||||
|  |         ILogger<ValidatePasswordGrantHandler> logger) | ||||||
|     { |     { | ||||||
|         this.registry = registry ?? throw new ArgumentNullException(nameof(registry)); |         this.registry = registry ?? throw new ArgumentNullException(nameof(registry)); | ||||||
|  |         this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource)); | ||||||
|  |         this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context) |     public ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context) | ||||||
| @@ -26,20 +35,29 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op | |||||||
|             return default; |             return default; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         using var activity = activitySource.StartActivity("authority.token.validate_password_grant", ActivityKind.Internal); | ||||||
|  |         activity?.SetTag("authority.endpoint", "/token"); | ||||||
|  |         activity?.SetTag("authority.grant_type", OpenIddictConstants.GrantTypes.Password); | ||||||
|  |         activity?.SetTag("authority.username", context.Request.Username ?? string.Empty); | ||||||
|  |  | ||||||
|         var selection = AuthorityIdentityProviderSelector.ResolvePasswordProvider(context.Request, registry); |         var selection = AuthorityIdentityProviderSelector.ResolvePasswordProvider(context.Request, registry); | ||||||
|         if (!selection.Succeeded) |         if (!selection.Succeeded) | ||||||
|         { |         { | ||||||
|             context.Reject(selection.Error!, selection.Description); |             context.Reject(selection.Error!, selection.Description); | ||||||
|  |             logger.LogWarning("Password grant validation failed for {Username}: {Reason}.", context.Request.Username, selection.Description); | ||||||
|             return default; |             return default; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if (string.IsNullOrWhiteSpace(context.Request.Username) || string.IsNullOrEmpty(context.Request.Password)) |         if (string.IsNullOrWhiteSpace(context.Request.Username) || string.IsNullOrEmpty(context.Request.Password)) | ||||||
|         { |         { | ||||||
|             context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Both username and password must be provided."); |             context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Both username and password must be provided."); | ||||||
|  |             logger.LogWarning("Password grant validation failed: missing credentials for {Username}.", context.Request.Username); | ||||||
|             return default; |             return default; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         context.Transaction.Properties[AuthorityOpenIddictConstants.ProviderTransactionProperty] = selection.Provider!.Name; |         context.Transaction.Properties[AuthorityOpenIddictConstants.ProviderTransactionProperty] = selection.Provider!.Name; | ||||||
|  |         activity?.SetTag("authority.identity_provider", selection.Provider.Name); | ||||||
|  |         logger.LogInformation("Password grant validation succeeded for {Username} using provider {Provider}.", context.Request.Username, selection.Provider.Name); | ||||||
|         return default; |         return default; | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -47,10 +65,17 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op | |||||||
| internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<OpenIddictServerEvents.HandleTokenRequestContext> | internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<OpenIddictServerEvents.HandleTokenRequestContext> | ||||||
| { | { | ||||||
|     private readonly IAuthorityIdentityProviderRegistry registry; |     private readonly IAuthorityIdentityProviderRegistry registry; | ||||||
|  |     private readonly ActivitySource activitySource; | ||||||
|  |     private readonly ILogger<HandlePasswordGrantHandler> logger; | ||||||
|  |  | ||||||
|     public HandlePasswordGrantHandler(IAuthorityIdentityProviderRegistry registry) |     public HandlePasswordGrantHandler( | ||||||
|  |         IAuthorityIdentityProviderRegistry registry, | ||||||
|  |         ActivitySource activitySource, | ||||||
|  |         ILogger<HandlePasswordGrantHandler> logger) | ||||||
|     { |     { | ||||||
|         this.registry = registry ?? throw new ArgumentNullException(nameof(registry)); |         this.registry = registry ?? throw new ArgumentNullException(nameof(registry)); | ||||||
|  |         this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource)); | ||||||
|  |         this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async ValueTask HandleAsync(OpenIddictServerEvents.HandleTokenRequestContext context) |     public async ValueTask HandleAsync(OpenIddictServerEvents.HandleTokenRequestContext context) | ||||||
| @@ -62,6 +87,11 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open | |||||||
|             return; |             return; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         using var activity = activitySource.StartActivity("authority.token.handle_password_grant", ActivityKind.Internal); | ||||||
|  |         activity?.SetTag("authority.endpoint", "/token"); | ||||||
|  |         activity?.SetTag("authority.grant_type", OpenIddictConstants.GrantTypes.Password); | ||||||
|  |         activity?.SetTag("authority.username", context.Request.Username ?? string.Empty); | ||||||
|  |  | ||||||
|         var providerName = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ProviderTransactionProperty, out var value) |         var providerName = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ProviderTransactionProperty, out var value) | ||||||
|             ? value as string |             ? value as string | ||||||
|             : null; |             : null; | ||||||
| @@ -72,6 +102,7 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open | |||||||
|             if (!registry.TryGet(providerName!, out var explicitProvider)) |             if (!registry.TryGet(providerName!, out var explicitProvider)) | ||||||
|             { |             { | ||||||
|                 context.Reject(OpenIddictConstants.Errors.ServerError, "Unable to resolve the requested identity provider."); |                 context.Reject(OpenIddictConstants.Errors.ServerError, "Unable to resolve the requested identity provider."); | ||||||
|  |                 logger.LogError("Password grant handling failed: provider {Provider} not found for user {Username}.", providerName, context.Request.Username); | ||||||
|                 return; |                 return; | ||||||
|             } |             } | ||||||
|  |  | ||||||
| @@ -83,6 +114,7 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open | |||||||
|             if (!selection.Succeeded) |             if (!selection.Succeeded) | ||||||
|             { |             { | ||||||
|                 context.Reject(selection.Error!, selection.Description); |                 context.Reject(selection.Error!, selection.Description); | ||||||
|  |                 logger.LogWarning("Password grant handling rejected {Username}: {Reason}.", context.Request.Username, selection.Description); | ||||||
|                 return; |                 return; | ||||||
|             } |             } | ||||||
|  |  | ||||||
| @@ -96,6 +128,7 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open | |||||||
|         if (string.IsNullOrWhiteSpace(username) || string.IsNullOrEmpty(password)) |         if (string.IsNullOrWhiteSpace(username) || string.IsNullOrEmpty(password)) | ||||||
|         { |         { | ||||||
|             context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Both username and password must be provided."); |             context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Both username and password must be provided."); | ||||||
|  |             logger.LogWarning("Password grant handling rejected: missing credentials for {Username}.", username); | ||||||
|             return; |             return; | ||||||
|         } |         } | ||||||
|  |  | ||||||
| @@ -109,6 +142,7 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open | |||||||
|             context.Reject( |             context.Reject( | ||||||
|                 OpenIddictConstants.Errors.InvalidGrant, |                 OpenIddictConstants.Errors.InvalidGrant, | ||||||
|                 verification.Message ?? "Invalid username or password."); |                 verification.Message ?? "Invalid username or password."); | ||||||
|  |             logger.LogWarning("Password verification failed for {Username}: {Message}.", username, verification.Message); | ||||||
|             return; |             return; | ||||||
|         } |         } | ||||||
|  |  | ||||||
| @@ -147,5 +181,7 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open | |||||||
|  |  | ||||||
|         context.Principal = principal; |         context.Principal = principal; | ||||||
|         context.HandleRequest(); |         context.HandleRequest(); | ||||||
|  |         activity?.SetTag("authority.subject_id", verification.User.SubjectId); | ||||||
|  |         logger.LogInformation("Password grant issued for {Username} with subject {SubjectId}.", verification.User.Username, verification.User.SubjectId); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,4 +1,6 @@ | |||||||
|  | using System.Diagnostics; | ||||||
| using System.Security.Claims; | using System.Security.Claims; | ||||||
|  | using Microsoft.Extensions.Logging; | ||||||
| using OpenIddict.Abstractions; | using OpenIddict.Abstractions; | ||||||
| using OpenIddict.Extensions; | using OpenIddict.Extensions; | ||||||
| using OpenIddict.Server; | using OpenIddict.Server; | ||||||
| @@ -16,17 +18,23 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open | |||||||
|     private readonly IAuthorityClientStore clientStore; |     private readonly IAuthorityClientStore clientStore; | ||||||
|     private readonly IAuthorityIdentityProviderRegistry registry; |     private readonly IAuthorityIdentityProviderRegistry registry; | ||||||
|     private readonly TimeProvider clock; |     private readonly TimeProvider clock; | ||||||
|  |     private readonly ActivitySource activitySource; | ||||||
|  |     private readonly ILogger<ValidateAccessTokenHandler> logger; | ||||||
|  |  | ||||||
|     public ValidateAccessTokenHandler( |     public ValidateAccessTokenHandler( | ||||||
|         IAuthorityTokenStore tokenStore, |         IAuthorityTokenStore tokenStore, | ||||||
|         IAuthorityClientStore clientStore, |         IAuthorityClientStore clientStore, | ||||||
|         IAuthorityIdentityProviderRegistry registry, |         IAuthorityIdentityProviderRegistry registry, | ||||||
|         TimeProvider clock) |         TimeProvider clock, | ||||||
|  |         ActivitySource activitySource, | ||||||
|  |         ILogger<ValidateAccessTokenHandler> logger) | ||||||
|     { |     { | ||||||
|         this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore)); |         this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore)); | ||||||
|         this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore)); |         this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore)); | ||||||
|         this.registry = registry ?? throw new ArgumentNullException(nameof(registry)); |         this.registry = registry ?? throw new ArgumentNullException(nameof(registry)); | ||||||
|         this.clock = clock ?? throw new ArgumentNullException(nameof(clock)); |         this.clock = clock ?? throw new ArgumentNullException(nameof(clock)); | ||||||
|  |         this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource)); | ||||||
|  |         this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenContext context) |     public async ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenContext context) | ||||||
| @@ -43,6 +51,14 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open | |||||||
|             return; |             return; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         using var activity = activitySource.StartActivity("authority.token.validate_access", ActivityKind.Internal); | ||||||
|  |         activity?.SetTag("authority.endpoint", context.EndpointType switch | ||||||
|  |         { | ||||||
|  |             OpenIddictServerEndpointType.Token => "/token", | ||||||
|  |             OpenIddictServerEndpointType.Introspection => "/introspect", | ||||||
|  |             _ => context.EndpointType.ToString() | ||||||
|  |         }); | ||||||
|  |  | ||||||
|         var tokenId = !string.IsNullOrWhiteSpace(context.TokenId) |         var tokenId = !string.IsNullOrWhiteSpace(context.TokenId) | ||||||
|             ? context.TokenId |             ? context.TokenId | ||||||
|             : context.Principal.GetClaim(OpenIddictConstants.Claims.JwtId); |             : context.Principal.GetClaim(OpenIddictConstants.Claims.JwtId); | ||||||
| @@ -55,16 +71,19 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open | |||||||
|                 if (!string.Equals(tokenDocument.Status, "valid", StringComparison.OrdinalIgnoreCase)) |                 if (!string.Equals(tokenDocument.Status, "valid", StringComparison.OrdinalIgnoreCase)) | ||||||
|                 { |                 { | ||||||
|                     context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token is no longer active."); |                     context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token is no longer active."); | ||||||
|  |                     logger.LogWarning("Access token {TokenId} rejected: status {Status}.", tokenId, tokenDocument.Status); | ||||||
|                     return; |                     return; | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 if (tokenDocument.ExpiresAt is { } expiresAt && expiresAt <= clock.GetUtcNow()) |                 if (tokenDocument.ExpiresAt is { } expiresAt && expiresAt <= clock.GetUtcNow()) | ||||||
|                 { |                 { | ||||||
|                     context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token has expired."); |                     context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token has expired."); | ||||||
|  |                     logger.LogWarning("Access token {TokenId} rejected: expired at {ExpiresAt:o}.", tokenId, expiresAt); | ||||||
|                     return; |                     return; | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 context.Transaction.Properties[AuthorityOpenIddictConstants.TokenTransactionProperty] = tokenDocument; |                 context.Transaction.Properties[AuthorityOpenIddictConstants.TokenTransactionProperty] = tokenDocument; | ||||||
|  |                 activity?.SetTag("authority.token_id", tokenDocument.TokenId); | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
| @@ -75,6 +94,7 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open | |||||||
|             if (clientDocument is null || clientDocument.Disabled) |             if (clientDocument is null || clientDocument.Disabled) | ||||||
|             { |             { | ||||||
|                 context.Reject(OpenIddictConstants.Errors.InvalidClient, "The client associated with the token is not permitted."); |                 context.Reject(OpenIddictConstants.Errors.InvalidClient, "The client associated with the token is not permitted."); | ||||||
|  |                 logger.LogWarning("Access token validation failed: client {ClientId} disabled or missing.", clientId); | ||||||
|                 return; |                 return; | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
| @@ -93,6 +113,7 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open | |||||||
|         if (!registry.TryGet(providerName, out var provider)) |         if (!registry.TryGet(providerName, out var provider)) | ||||||
|         { |         { | ||||||
|             context.Reject(OpenIddictConstants.Errors.InvalidToken, "The identity provider associated with the token is unavailable."); |             context.Reject(OpenIddictConstants.Errors.InvalidToken, "The identity provider associated with the token is unavailable."); | ||||||
|  |             logger.LogWarning("Access token validation failed: provider {Provider} unavailable for subject {Subject}.", providerName, context.Principal.GetClaim(OpenIddictConstants.Claims.Subject)); | ||||||
|             return; |             return; | ||||||
|         } |         } | ||||||
|  |  | ||||||
| @@ -106,8 +127,10 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open | |||||||
|             if (user is null) |             if (user is null) | ||||||
|             { |             { | ||||||
|                 context.Reject(OpenIddictConstants.Errors.InvalidToken, "The subject referenced by the token no longer exists."); |                 context.Reject(OpenIddictConstants.Errors.InvalidToken, "The subject referenced by the token no longer exists."); | ||||||
|  |                 logger.LogWarning("Access token validation failed: subject {SubjectId} not found.", subject); | ||||||
|                 return; |                 return; | ||||||
|             } |             } | ||||||
|  |             activity?.SetTag("authority.subject_id", subject); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if (!string.IsNullOrWhiteSpace(clientId) && provider.ClientProvisioning is not null) |         if (!string.IsNullOrWhiteSpace(clientId) && provider.ClientProvisioning is not null) | ||||||
| @@ -117,5 +140,8 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open | |||||||
|  |  | ||||||
|         var enrichmentContext = new AuthorityClaimsEnrichmentContext(provider.Context, user, client); |         var enrichmentContext = new AuthorityClaimsEnrichmentContext(provider.Context, user, client); | ||||||
|         await provider.ClaimsEnricher.EnrichAsync(identity, enrichmentContext, context.CancellationToken).ConfigureAwait(false); |         await provider.ClaimsEnricher.EnrichAsync(identity, enrichmentContext, context.CancellationToken).ConfigureAwait(false); | ||||||
|  |         logger.LogInformation("Access token validated for subject {Subject} and client {ClientId}.", | ||||||
|  |             identity.GetClaim(OpenIddictConstants.Claims.Subject), | ||||||
|  |             identity.GetClaim(OpenIddictConstants.Claims.ClientId)); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; | |||||||
| using Microsoft.Extensions.Configuration; | using Microsoft.Extensions.Configuration; | ||||||
| using Microsoft.Extensions.Hosting; | using Microsoft.Extensions.Hosting; | ||||||
| using Microsoft.Extensions.Options; | using Microsoft.Extensions.Options; | ||||||
|  | using Microsoft.AspNetCore.RateLimiting; | ||||||
| using Microsoft.Extensions.Logging.Abstractions; | using Microsoft.Extensions.Logging.Abstractions; | ||||||
| using OpenIddict.Abstractions; | using OpenIddict.Abstractions; | ||||||
| using OpenIddict.Server; | using OpenIddict.Server; | ||||||
| @@ -68,6 +69,11 @@ var issuer = authorityOptions.Issuer ?? throw new InvalidOperationException("Aut | |||||||
| builder.Services.AddSingleton(authorityOptions); | builder.Services.AddSingleton(authorityOptions); | ||||||
| builder.Services.AddSingleton<IOptions<StellaOpsAuthorityOptions>>(Options.Create(authorityOptions)); | builder.Services.AddSingleton<IOptions<StellaOpsAuthorityOptions>>(Options.Create(authorityOptions)); | ||||||
|  |  | ||||||
|  | builder.Services.AddRateLimiter(rateLimiterOptions => | ||||||
|  | { | ||||||
|  |     AuthorityRateLimiter.Configure(rateLimiterOptions, authorityOptions); | ||||||
|  | }); | ||||||
|  |  | ||||||
| AuthorityPluginContext[] pluginContexts = AuthorityPluginConfigurationLoader | AuthorityPluginContext[] pluginContexts = AuthorityPluginConfigurationLoader | ||||||
|     .Load(authorityOptions, builder.Environment.ContentRootPath) |     .Load(authorityOptions, builder.Environment.ContentRootPath) | ||||||
|     .ToArray(); |     .ToArray(); | ||||||
| @@ -392,6 +398,7 @@ app.UseExceptionHandler(static errorApp => | |||||||
| }); | }); | ||||||
|  |  | ||||||
| app.UseRouting(); | app.UseRouting(); | ||||||
|  | app.UseRateLimiter(); | ||||||
| app.UseAuthentication(); | app.UseAuthentication(); | ||||||
| app.UseAuthorization(); | app.UseAuthorization(); | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										15
									
								
								src/StellaOps.Authority/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/StellaOps.Authority/TASKS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | |||||||
|  | # Authority Host Task Board (UTC 2025-10-10) | ||||||
|  |  | ||||||
|  | | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||||
|  | |----|--------|----------|------------|-------------|---------------| | ||||||
|  | | CORE5B.DOC | TODO | Authority Core, Docs Guild | CORE5 | Document token persistence, revocation semantics, and enrichment expectations for resource servers/plugins. | ✅ `docs/11_AUTHORITY.md` + plugin guide updated with claims + token store notes; ✅ Samples include revocation sync guidance. | | ||||||
|  | | CORE9.REVOCATION | TODO | Authority Core, Security Guild | CORE5 | Implement revocation list persistence + export hooks (API + CLI). | ✅ Revoked tokens denied; ✅ Export endpoint/CLI returns manifest; ✅ Tests cover offline bundle flow. | | ||||||
|  | | CORE10.JWKS | TODO | Authority Core, DevOps | CORE9.REVOCATION | Provide JWKS rotation with pluggable key loader + documentation. | ✅ Signing/encryption keys rotate without downtime; ✅ JWKS endpoint updates; ✅ Docs describe rotation SOP. | | ||||||
|  | | CORE8.RL | BLOCKED (Team 2) | Authority Core | CORE8 | Deliver ASP.NET rate limiter plumbing (request metadata, dependency injection hooks) needed by Security Guild. | ✅ `/token` & `/authorize` pipelines expose limiter hooks; ✅ Tests cover throttle behaviour baseline. | | ||||||
|  | | SEC2.HOST | TODO | Security Guild, Authority Core | SEC2.A (audit contract) | Hook audit logger into OpenIddict handlers and bootstrap endpoints. | ✅ Audit events populated with correlationId, IP, client_id; ✅ Mongo login attempts persisted; ✅ Tests verify on success/failure/lockout. | | ||||||
|  | | SEC3.HOST | TODO | Security Guild | CORE8.RL, SEC3.A (rate policy) | Apply rate limiter policies (`AddRateLimiter`) to `/token` and `/internal/*` endpoints with configuration binding. | ✅ Policies configurable via `StellaOpsAuthorityOptions.Security.RateLimiting`; ✅ Integration tests hit 429 after limit; ✅ Docs updated. | | ||||||
|  | | SEC4.HOST | TODO | Security Guild, DevOps | SEC4.A (revocation schema) | Implement CLI/HTTP surface to export revocation bundle + detached JWS using `StellaOps.Cryptography`. | ✅ `stellaops auth revoke export` CLI/endpoint returns JSON + `.jws`; ✅ Verification script passes; ✅ Operator docs updated. | | ||||||
|  | | SEC4.KEY | TODO | Security Guild, DevOps | SEC4.HOST | Integrate signing keys with provider registry (initial ES256). | ✅ Keys loaded via `ICryptoProvider` signer; ✅ Rotation SOP documented. | | ||||||
|  | | SEC5.HOST | TODO | Security Guild | SEC5.A (threat model) | Feed Authority-specific mitigations (rate limiting, audit, revocation) into threat model + backlog. | ✅ Threat model updated; ✅ Backlog issues reference mitigations; ✅ Review sign-off captured. | | ||||||
|  |  | ||||||
|  | > Update status columns (TODO / DOING / DONE / BLOCKED) together with code changes. Always run `dotnet test src/StellaOps.Authority.sln` when touching host logic. | ||||||
| @@ -1,6 +1,8 @@ | |||||||
| using System; | using System; | ||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using System.IO; | using System.IO; | ||||||
|  | using System.Text; | ||||||
|  | using System.Text.Json; | ||||||
| using System.Threading; | using System.Threading; | ||||||
| using System.Threading.Tasks; | using System.Threading.Tasks; | ||||||
| using Microsoft.Extensions.DependencyInjection; | using Microsoft.Extensions.DependencyInjection; | ||||||
| @@ -8,14 +10,14 @@ using Microsoft.Extensions.Logging; | |||||||
| using Microsoft.IdentityModel.Tokens; | using Microsoft.IdentityModel.Tokens; | ||||||
| using StellaOps.Auth.Abstractions; | using StellaOps.Auth.Abstractions; | ||||||
| using StellaOps.Auth.Client; | using StellaOps.Auth.Client; | ||||||
| using StellaOps.Cli.Commands; | using StellaOps.Cli.Commands; | ||||||
| using StellaOps.Cli.Configuration; | using StellaOps.Cli.Configuration; | ||||||
| using StellaOps.Cli.Services; | using StellaOps.Cli.Services; | ||||||
| using StellaOps.Cli.Services.Models; | using StellaOps.Cli.Services.Models; | ||||||
| using StellaOps.Cli.Telemetry; | using StellaOps.Cli.Telemetry; | ||||||
| using StellaOps.Cli.Tests.Testing; | using StellaOps.Cli.Tests.Testing; | ||||||
|  |  | ||||||
| namespace StellaOps.Cli.Tests.Commands; | namespace StellaOps.Cli.Tests.Commands; | ||||||
|  |  | ||||||
| public sealed class CommandHandlersTests | public sealed class CommandHandlersTests | ||||||
| { | { | ||||||
| @@ -244,6 +246,80 @@ public sealed class CommandHandlersTests | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     [Fact] | ||||||
|  |     public async Task HandleAuthWhoAmIAsync_ReturnsErrorWhenTokenMissing() | ||||||
|  |     { | ||||||
|  |         var original = Environment.ExitCode; | ||||||
|  |         using var tempDir = new TempDirectory(); | ||||||
|  |  | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             var options = new StellaOpsCliOptions | ||||||
|  |             { | ||||||
|  |                 ResultsDirectory = Path.Combine(tempDir.Path, "results"), | ||||||
|  |                 Authority = new StellaOpsCliAuthorityOptions | ||||||
|  |                 { | ||||||
|  |                     Url = "https://authority.example", | ||||||
|  |                     ClientId = "cli", | ||||||
|  |                     TokenCacheDirectory = tempDir.Path | ||||||
|  |                 } | ||||||
|  |             }; | ||||||
|  |  | ||||||
|  |             var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: new StubTokenClient()); | ||||||
|  |  | ||||||
|  |             await CommandHandlers.HandleAuthWhoAmIAsync(provider, options, verbose: false, cancellationToken: CancellationToken.None); | ||||||
|  |  | ||||||
|  |             Assert.Equal(1, Environment.ExitCode); | ||||||
|  |         } | ||||||
|  |         finally | ||||||
|  |         { | ||||||
|  |             Environment.ExitCode = original; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [Fact] | ||||||
|  |     public async Task HandleAuthWhoAmIAsync_ReportsClaimsForJwtToken() | ||||||
|  |     { | ||||||
|  |         var original = Environment.ExitCode; | ||||||
|  |         using var tempDir = new TempDirectory(); | ||||||
|  |  | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             var options = new StellaOpsCliOptions | ||||||
|  |             { | ||||||
|  |                 ResultsDirectory = Path.Combine(tempDir.Path, "results"), | ||||||
|  |                 Authority = new StellaOpsCliAuthorityOptions | ||||||
|  |                 { | ||||||
|  |                     Url = "https://authority.example", | ||||||
|  |                     ClientId = "cli", | ||||||
|  |                     TokenCacheDirectory = tempDir.Path | ||||||
|  |                 } | ||||||
|  |             }; | ||||||
|  |  | ||||||
|  |             var tokenClient = new StubTokenClient(); | ||||||
|  |             tokenClient.CachedEntry = new StellaOpsTokenCacheEntry( | ||||||
|  |                 CreateUnsignedJwt( | ||||||
|  |                     ("sub", "cli-user"), | ||||||
|  |                     ("aud", "feedser"), | ||||||
|  |                     ("iss", "https://authority.example"), | ||||||
|  |                     ("iat", 1_700_000_000), | ||||||
|  |                     ("nbf", 1_700_000_000)), | ||||||
|  |                 "Bearer", | ||||||
|  |                 DateTimeOffset.UtcNow.AddMinutes(30), | ||||||
|  |                 new[] { StellaOpsScopes.FeedserJobsTrigger }); | ||||||
|  |  | ||||||
|  |             var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: tokenClient); | ||||||
|  |  | ||||||
|  |             await CommandHandlers.HandleAuthWhoAmIAsync(provider, options, verbose: true, cancellationToken: CancellationToken.None); | ||||||
|  |  | ||||||
|  |             Assert.Equal(0, Environment.ExitCode); | ||||||
|  |         } | ||||||
|  |         finally | ||||||
|  |         { | ||||||
|  |             Environment.ExitCode = original; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|     [Fact] |     [Fact] | ||||||
|     public async Task HandleAuthLogoutAsync_ClearsToken() |     public async Task HandleAuthLogoutAsync_ClearsToken() | ||||||
|     { |     { | ||||||
| @@ -432,4 +508,26 @@ public sealed class CommandHandlersTests | |||||||
|             return Task.FromResult(_token); |             return Task.FromResult(_token); | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     private static string CreateUnsignedJwt(params (string Key, object Value)[] claims) | ||||||
|  |     { | ||||||
|  |         var headerJson = "{\"alg\":\"none\",\"typ\":\"JWT\"}"; | ||||||
|  |         var payload = new Dictionary<string, object?>(StringComparer.Ordinal); | ||||||
|  |         foreach (var claim in claims) | ||||||
|  |         { | ||||||
|  |             payload[claim.Key] = claim.Value; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var payloadJson = JsonSerializer.Serialize(payload); | ||||||
|  |         return $"{Base64UrlEncode(headerJson)}.{Base64UrlEncode(payloadJson)}."; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string Base64UrlEncode(string value) | ||||||
|  |     { | ||||||
|  |         var bytes = Encoding.UTF8.GetBytes(value); | ||||||
|  |         return Convert.ToBase64String(bytes) | ||||||
|  |             .TrimEnd('=') | ||||||
|  |             .Replace('+', '-') | ||||||
|  |             .Replace('/', '_'); | ||||||
|  |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -25,6 +25,10 @@ public sealed class CliBootstrapperTests : IDisposable | |||||||
|         Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_URL", "https://authority.env"); |         Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_URL", "https://authority.env"); | ||||||
|         Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_CLIENT_ID", "cli-env"); |         Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_CLIENT_ID", "cli-env"); | ||||||
|         Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_SCOPE", "feedser.jobs.trigger"); |         Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_SCOPE", "feedser.jobs.trigger"); | ||||||
|  |         Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_ENABLE_RETRIES", "false"); | ||||||
|  |         Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_RETRY_DELAYS", "00:00:02,00:00:05"); | ||||||
|  |         Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_ALLOW_OFFLINE_CACHE_FALLBACK", "false"); | ||||||
|  |         Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_OFFLINE_CACHE_TOLERANCE", "00:20:00"); | ||||||
|  |  | ||||||
|         try |         try | ||||||
|         { |         { | ||||||
| @@ -35,6 +39,12 @@ public sealed class CliBootstrapperTests : IDisposable | |||||||
|             Assert.Equal("https://authority.env", options.Authority.Url); |             Assert.Equal("https://authority.env", options.Authority.Url); | ||||||
|             Assert.Equal("cli-env", options.Authority.ClientId); |             Assert.Equal("cli-env", options.Authority.ClientId); | ||||||
|             Assert.Equal("feedser.jobs.trigger", options.Authority.Scope); |             Assert.Equal("feedser.jobs.trigger", options.Authority.Scope); | ||||||
|  |  | ||||||
|  |             Assert.NotNull(options.Authority.Resilience); | ||||||
|  |             Assert.False(options.Authority.Resilience.EnableRetries); | ||||||
|  |             Assert.Equal(new[] { TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5) }, options.Authority.Resilience.RetryDelays); | ||||||
|  |             Assert.False(options.Authority.Resilience.AllowOfflineCacheFallback); | ||||||
|  |             Assert.Equal(TimeSpan.FromMinutes(20), options.Authority.Resilience.OfflineCacheTolerance); | ||||||
|         } |         } | ||||||
|         finally |         finally | ||||||
|         { |         { | ||||||
| @@ -43,6 +53,10 @@ public sealed class CliBootstrapperTests : IDisposable | |||||||
|             Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_URL", null); |             Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_URL", null); | ||||||
|             Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_CLIENT_ID", null); |             Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_CLIENT_ID", null); | ||||||
|             Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_SCOPE", null); |             Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_SCOPE", null); | ||||||
|  |             Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_ENABLE_RETRIES", null); | ||||||
|  |             Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_RETRY_DELAYS", null); | ||||||
|  |             Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_ALLOW_OFFLINE_CACHE_FALLBACK", null); | ||||||
|  |             Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_OFFLINE_CACHE_TOLERANCE", null); | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -255,35 +255,51 @@ internal static class CommandFactory | |||||||
|             return CommandHandlers.HandleAuthStatusAsync(services, options, verbose, cancellationToken); |             return CommandHandlers.HandleAuthStatusAsync(services, options, verbose, cancellationToken); | ||||||
|         }); |         }); | ||||||
|  |  | ||||||
|  |         var whoami = new Command("whoami", "Display cached token claims (subject, scopes, expiry)."); | ||||||
|  |         whoami.SetAction((parseResult, _) => | ||||||
|  |         { | ||||||
|  |             var verbose = parseResult.GetValue(verboseOption); | ||||||
|  |             return CommandHandlers.HandleAuthWhoAmIAsync(services, options, verbose, cancellationToken); | ||||||
|  |         }); | ||||||
|  |  | ||||||
|         auth.Add(login); |         auth.Add(login); | ||||||
|         auth.Add(logout); |         auth.Add(logout); | ||||||
|         auth.Add(status); |         auth.Add(status); | ||||||
|  |         auth.Add(whoami); | ||||||
|         return auth; |         return auth; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private static Command BuildConfigCommand(StellaOpsCliOptions options) |     private static Command BuildConfigCommand(StellaOpsCliOptions options) | ||||||
|     { |     { | ||||||
|         var config = new Command("config", "Inspect CLI configuration state."); |         var config = new Command("config", "Inspect CLI configuration state."); | ||||||
|         var show = new Command("show", "Display resolved configuration values."); |         var show = new Command("show", "Display resolved configuration values."); | ||||||
|  |  | ||||||
|         show.SetAction((_, _) => |         show.SetAction((_, _) => | ||||||
|         { |         { | ||||||
|             var lines = new[] |             var authority = options.Authority ?? new StellaOpsCliAuthorityOptions(); | ||||||
|             { |             var lines = new[] | ||||||
|                 $"Backend URL: {MaskIfEmpty(options.BackendUrl)}", |             { | ||||||
|                 $"API Key: {DescribeSecret(options.ApiKey)}", |                 $"Backend URL: {MaskIfEmpty(options.BackendUrl)}", | ||||||
|                 $"Scanner Cache: {options.ScannerCacheDirectory}", |                 $"API Key: {DescribeSecret(options.ApiKey)}", | ||||||
|                 $"Results Directory: {options.ResultsDirectory}", |                 $"Scanner Cache: {options.ScannerCacheDirectory}", | ||||||
|                 $"Default Runner: {options.DefaultRunner}" |                 $"Results Directory: {options.ResultsDirectory}", | ||||||
|             }; |                 $"Default Runner: {options.DefaultRunner}", | ||||||
|  |                 $"Authority URL: {MaskIfEmpty(authority.Url)}", | ||||||
|             foreach (var line in lines) |                 $"Authority Client ID: {MaskIfEmpty(authority.ClientId)}", | ||||||
|             { |                 $"Authority Client Secret: {DescribeSecret(authority.ClientSecret ?? string.Empty)}", | ||||||
|                 Console.WriteLine(line); |                 $"Authority Username: {MaskIfEmpty(authority.Username)}", | ||||||
|             } |                 $"Authority Password: {DescribeSecret(authority.Password ?? string.Empty)}", | ||||||
|  |                 $"Authority Scope: {MaskIfEmpty(authority.Scope)}", | ||||||
|             return Task.CompletedTask; |                 $"Authority Token Cache: {MaskIfEmpty(authority.TokenCacheDirectory ?? string.Empty)}" | ||||||
|         }); |             }; | ||||||
|  |  | ||||||
|  |             foreach (var line in lines) | ||||||
|  |             { | ||||||
|  |                 Console.WriteLine(line); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             return Task.CompletedTask; | ||||||
|  |         }); | ||||||
|  |  | ||||||
|         config.Add(show); |         config.Add(show); | ||||||
|         return config; |         return config; | ||||||
|   | |||||||
| @@ -1,9 +1,11 @@ | |||||||
| using System; | using System; | ||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using System.Diagnostics; | using System.Diagnostics; | ||||||
| using System.IO; | using System.Globalization; | ||||||
| using System.Threading; | using System.IO; | ||||||
| using System.Threading.Tasks; | using System.Text.Json; | ||||||
|  | using System.Threading; | ||||||
|  | using System.Threading.Tasks; | ||||||
| using Microsoft.Extensions.DependencyInjection; | using Microsoft.Extensions.DependencyInjection; | ||||||
| using Microsoft.Extensions.Logging; | using Microsoft.Extensions.Logging; | ||||||
| using Spectre.Console; | using Spectre.Console; | ||||||
| @@ -492,11 +494,322 @@ internal static class CommandHandlers | |||||||
|             logger.LogInformation("Scopes: {Scopes}", string.Join(", ", entry.Scopes)); |             logger.LogInformation("Scopes: {Scopes}", string.Join(", ", entry.Scopes)); | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private static async Task TriggerJobAsync( |     public static async Task HandleAuthWhoAmIAsync( | ||||||
|         IBackendOperationsClient client, |         IServiceProvider services, | ||||||
|         ILogger logger, |         StellaOpsCliOptions options, | ||||||
|         string jobKind, |         bool verbose, | ||||||
|  |         CancellationToken cancellationToken) | ||||||
|  |     { | ||||||
|  |         await using var scope = services.CreateAsyncScope(); | ||||||
|  |         var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("auth-whoami"); | ||||||
|  |         Environment.ExitCode = 0; | ||||||
|  |  | ||||||
|  |         if (string.IsNullOrWhiteSpace(options.Authority?.Url)) | ||||||
|  |         { | ||||||
|  |             logger.LogInformation("Authority URL not configured. Set STELLAOPS_AUTHORITY_URL and run 'auth login'."); | ||||||
|  |             Environment.ExitCode = 1; | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var tokenClient = scope.ServiceProvider.GetService<IStellaOpsTokenClient>(); | ||||||
|  |         if (tokenClient is null) | ||||||
|  |         { | ||||||
|  |             logger.LogInformation("Authority client not registered; no cached tokens available."); | ||||||
|  |             Environment.ExitCode = 1; | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var cacheKey = AuthorityTokenUtilities.BuildCacheKey(options); | ||||||
|  |         if (string.IsNullOrWhiteSpace(cacheKey)) | ||||||
|  |         { | ||||||
|  |             logger.LogInformation("Authority configuration incomplete; no cached tokens available."); | ||||||
|  |             Environment.ExitCode = 1; | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var entry = await tokenClient.GetCachedTokenAsync(cacheKey, cancellationToken).ConfigureAwait(false); | ||||||
|  |         if (entry is null) | ||||||
|  |         { | ||||||
|  |             logger.LogInformation("No cached token for {Authority}. Run 'auth login' to authenticate.", options.Authority.Url); | ||||||
|  |             Environment.ExitCode = 1; | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var grantType = string.IsNullOrWhiteSpace(options.Authority.Username) ? "client_credentials" : "password"; | ||||||
|  |         var now = DateTimeOffset.UtcNow; | ||||||
|  |         var remaining = entry.ExpiresAtUtc - now; | ||||||
|  |         if (remaining < TimeSpan.Zero) | ||||||
|  |         { | ||||||
|  |             remaining = TimeSpan.Zero; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         logger.LogInformation("Authority: {Authority}", options.Authority.Url); | ||||||
|  |         logger.LogInformation("Grant type: {GrantType}", grantType); | ||||||
|  |         logger.LogInformation("Token type: {TokenType}", entry.TokenType); | ||||||
|  |         logger.LogInformation("Expires: {Expires} ({Remaining})", entry.ExpiresAtUtc.ToString("u"), FormatDuration(remaining)); | ||||||
|  |  | ||||||
|  |         if (entry.Scopes.Count > 0) | ||||||
|  |         { | ||||||
|  |             logger.LogInformation("Scopes: {Scopes}", string.Join(", ", entry.Scopes)); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (TryExtractJwtClaims(entry.AccessToken, out var claims, out var issuedAt, out var notBefore)) | ||||||
|  |         { | ||||||
|  |             if (claims.TryGetValue("sub", out var subject) && !string.IsNullOrWhiteSpace(subject)) | ||||||
|  |             { | ||||||
|  |                 logger.LogInformation("Subject: {Subject}", subject); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (claims.TryGetValue("client_id", out var clientId) && !string.IsNullOrWhiteSpace(clientId)) | ||||||
|  |             { | ||||||
|  |                 logger.LogInformation("Client ID (token): {ClientId}", clientId); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (claims.TryGetValue("aud", out var audience) && !string.IsNullOrWhiteSpace(audience)) | ||||||
|  |             { | ||||||
|  |                 logger.LogInformation("Audience: {Audience}", audience); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (claims.TryGetValue("iss", out var issuer) && !string.IsNullOrWhiteSpace(issuer)) | ||||||
|  |             { | ||||||
|  |                 logger.LogInformation("Issuer: {Issuer}", issuer); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (issuedAt is not null) | ||||||
|  |             { | ||||||
|  |                 logger.LogInformation("Issued at: {IssuedAt}", issuedAt.Value.ToString("u")); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (notBefore is not null) | ||||||
|  |             { | ||||||
|  |                 logger.LogInformation("Not before: {NotBefore}", notBefore.Value.ToString("u")); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var extraClaims = CollectAdditionalClaims(claims); | ||||||
|  |             if (extraClaims.Count > 0 && verbose) | ||||||
|  |             { | ||||||
|  |                 logger.LogInformation("Additional claims: {Claims}", string.Join(", ", extraClaims)); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         else | ||||||
|  |         { | ||||||
|  |             logger.LogInformation("Access token appears opaque; claims are unavailable."); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string FormatDuration(TimeSpan duration) | ||||||
|  |     { | ||||||
|  |         if (duration <= TimeSpan.Zero) | ||||||
|  |         { | ||||||
|  |             return "expired"; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (duration.TotalDays >= 1) | ||||||
|  |         { | ||||||
|  |             var days = (int)duration.TotalDays; | ||||||
|  |             var hours = duration.Hours; | ||||||
|  |             return hours > 0 | ||||||
|  |                 ? FormattableString.Invariant($"{days}d {hours}h") | ||||||
|  |                 : FormattableString.Invariant($"{days}d"); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (duration.TotalHours >= 1) | ||||||
|  |         { | ||||||
|  |             return FormattableString.Invariant($"{(int)duration.TotalHours}h {duration.Minutes}m"); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (duration.TotalMinutes >= 1) | ||||||
|  |         { | ||||||
|  |             return FormattableString.Invariant($"{(int)duration.TotalMinutes}m {duration.Seconds}s"); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return FormattableString.Invariant($"{duration.Seconds}s"); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static bool TryExtractJwtClaims( | ||||||
|  |         string accessToken, | ||||||
|  |         out Dictionary<string, string> claims, | ||||||
|  |         out DateTimeOffset? issuedAt, | ||||||
|  |         out DateTimeOffset? notBefore) | ||||||
|  |     { | ||||||
|  |         claims = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); | ||||||
|  |         issuedAt = null; | ||||||
|  |         notBefore = null; | ||||||
|  |  | ||||||
|  |         if (string.IsNullOrWhiteSpace(accessToken)) | ||||||
|  |         { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var parts = accessToken.Split('.'); | ||||||
|  |         if (parts.Length < 2) | ||||||
|  |         { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (!TryDecodeBase64Url(parts[1], out var payloadBytes)) | ||||||
|  |         { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             using var document = JsonDocument.Parse(payloadBytes); | ||||||
|  |             foreach (var property in document.RootElement.EnumerateObject()) | ||||||
|  |             { | ||||||
|  |                 var value = FormatJsonValue(property.Value); | ||||||
|  |                 claims[property.Name] = value; | ||||||
|  |  | ||||||
|  |                 if (issuedAt is null && property.NameEquals("iat") && TryParseUnixSeconds(property.Value, out var parsedIat)) | ||||||
|  |                 { | ||||||
|  |                     issuedAt = parsedIat; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 if (notBefore is null && property.NameEquals("nbf") && TryParseUnixSeconds(property.Value, out var parsedNbf)) | ||||||
|  |                 { | ||||||
|  |                     notBefore = parsedNbf; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             return true; | ||||||
|  |         } | ||||||
|  |         catch (JsonException) | ||||||
|  |         { | ||||||
|  |             claims.Clear(); | ||||||
|  |             issuedAt = null; | ||||||
|  |             notBefore = null; | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static bool TryDecodeBase64Url(string value, out byte[] bytes) | ||||||
|  |     { | ||||||
|  |         bytes = Array.Empty<byte>(); | ||||||
|  |  | ||||||
|  |         if (string.IsNullOrWhiteSpace(value)) | ||||||
|  |         { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var normalized = value.Replace('-', '+').Replace('_', '/'); | ||||||
|  |         var padding = normalized.Length % 4; | ||||||
|  |         if (padding is 2 or 3) | ||||||
|  |         { | ||||||
|  |             normalized = normalized.PadRight(normalized.Length + (4 - padding), '='); | ||||||
|  |         } | ||||||
|  |         else if (padding == 1) | ||||||
|  |         { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             bytes = Convert.FromBase64String(normalized); | ||||||
|  |             return true; | ||||||
|  |         } | ||||||
|  |         catch (FormatException) | ||||||
|  |         { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string FormatJsonValue(JsonElement element) | ||||||
|  |     { | ||||||
|  |         return element.ValueKind switch | ||||||
|  |         { | ||||||
|  |             JsonValueKind.String => element.GetString() ?? string.Empty, | ||||||
|  |             JsonValueKind.Number => element.TryGetInt64(out var longValue) | ||||||
|  |                 ? longValue.ToString(CultureInfo.InvariantCulture) | ||||||
|  |                 : element.GetDouble().ToString(CultureInfo.InvariantCulture), | ||||||
|  |             JsonValueKind.True => "true", | ||||||
|  |             JsonValueKind.False => "false", | ||||||
|  |             JsonValueKind.Null => "null", | ||||||
|  |             JsonValueKind.Array => FormatArray(element), | ||||||
|  |             JsonValueKind.Object => element.GetRawText(), | ||||||
|  |             _ => element.GetRawText() | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string FormatArray(JsonElement array) | ||||||
|  |     { | ||||||
|  |         var values = new List<string>(); | ||||||
|  |         foreach (var item in array.EnumerateArray()) | ||||||
|  |         { | ||||||
|  |             values.Add(FormatJsonValue(item)); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return string.Join(", ", values); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static bool TryParseUnixSeconds(JsonElement element, out DateTimeOffset value) | ||||||
|  |     { | ||||||
|  |         value = default; | ||||||
|  |  | ||||||
|  |         if (element.ValueKind == JsonValueKind.Number) | ||||||
|  |         { | ||||||
|  |             if (element.TryGetInt64(out var seconds)) | ||||||
|  |             { | ||||||
|  |                 value = DateTimeOffset.FromUnixTimeSeconds(seconds); | ||||||
|  |                 return true; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (element.TryGetDouble(out var doubleValue)) | ||||||
|  |             { | ||||||
|  |                 value = DateTimeOffset.FromUnixTimeSeconds((long)doubleValue); | ||||||
|  |                 return true; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (element.ValueKind == JsonValueKind.String) | ||||||
|  |         { | ||||||
|  |             var text = element.GetString(); | ||||||
|  |             if (!string.IsNullOrWhiteSpace(text) && long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds)) | ||||||
|  |             { | ||||||
|  |                 value = DateTimeOffset.FromUnixTimeSeconds(seconds); | ||||||
|  |                 return true; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static List<string> CollectAdditionalClaims(Dictionary<string, string> claims) | ||||||
|  |     { | ||||||
|  |         var result = new List<string>(); | ||||||
|  |         foreach (var pair in claims) | ||||||
|  |         { | ||||||
|  |             if (CommonClaimNames.Contains(pair.Key)) | ||||||
|  |             { | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             result.Add(FormattableString.Invariant($"{pair.Key}={pair.Value}")); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         result.Sort(StringComparer.OrdinalIgnoreCase); | ||||||
|  |         return result; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static readonly HashSet<string> CommonClaimNames = new(StringComparer.OrdinalIgnoreCase) | ||||||
|  |     { | ||||||
|  |         "aud", | ||||||
|  |         "client_id", | ||||||
|  |         "exp", | ||||||
|  |         "iat", | ||||||
|  |         "iss", | ||||||
|  |         "nbf", | ||||||
|  |         "scope", | ||||||
|  |         "scopes", | ||||||
|  |         "sub", | ||||||
|  |         "token_type", | ||||||
|  |         "jti" | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     private static async Task TriggerJobAsync( | ||||||
|  |         IBackendOperationsClient client, | ||||||
|  |         ILogger logger, | ||||||
|  |         string jobKind, | ||||||
|         IDictionary<string, object?> parameters, |         IDictionary<string, object?> parameters, | ||||||
|         CancellationToken cancellationToken) |         CancellationToken cancellationToken) | ||||||
|     { |     { | ||||||
| @@ -522,6 +835,6 @@ internal static class CommandHandlers | |||||||
|         { |         { | ||||||
|             logger.LogError("Job '{JobKind}' failed: {Message}", jobKind, result.Message); |             logger.LogError("Job '{JobKind}' failed: {Message}", jobKind, result.Message); | ||||||
|             Environment.ExitCode = 1; |             Environment.ExitCode = 1; | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,3 +1,5 @@ | |||||||
|  | using System; | ||||||
|  | using System.Collections.Generic; | ||||||
| using System.Globalization; | using System.Globalization; | ||||||
| using System.IO; | using System.IO; | ||||||
| using Microsoft.Extensions.Configuration; | using Microsoft.Extensions.Configuration; | ||||||
| @@ -113,6 +115,82 @@ public static class CliBootstrapper | |||||||
|                 authority.Password = string.IsNullOrWhiteSpace(authority.Password) ? null : authority.Password.Trim(); |                 authority.Password = string.IsNullOrWhiteSpace(authority.Password) ? null : authority.Password.Trim(); | ||||||
|                 authority.Scope = string.IsNullOrWhiteSpace(authority.Scope) ? StellaOpsScopes.FeedserJobsTrigger : authority.Scope.Trim(); |                 authority.Scope = string.IsNullOrWhiteSpace(authority.Scope) ? StellaOpsScopes.FeedserJobsTrigger : authority.Scope.Trim(); | ||||||
|  |  | ||||||
|  |                 authority.Resilience ??= new StellaOpsCliAuthorityResilienceOptions(); | ||||||
|  |                 authority.Resilience.RetryDelays ??= new List<TimeSpan>(); | ||||||
|  |                 var resilience = authority.Resilience; | ||||||
|  |  | ||||||
|  |                 if (!resilience.EnableRetries.HasValue) | ||||||
|  |                 { | ||||||
|  |                     var raw = ResolveWithFallback( | ||||||
|  |                         string.Empty, | ||||||
|  |                         configuration, | ||||||
|  |                         "STELLAOPS_AUTHORITY_ENABLE_RETRIES", | ||||||
|  |                         "StellaOps:Authority:Resilience:EnableRetries", | ||||||
|  |                         "StellaOps:Authority:EnableRetries", | ||||||
|  |                         "Authority:Resilience:EnableRetries", | ||||||
|  |                         "Authority:EnableRetries"); | ||||||
|  |  | ||||||
|  |                     if (TryParseBoolean(raw, out var parsed)) | ||||||
|  |                     { | ||||||
|  |                         resilience.EnableRetries = parsed; | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 var retryDelaysRaw = ResolveWithFallback( | ||||||
|  |                     string.Empty, | ||||||
|  |                     configuration, | ||||||
|  |                     "STELLAOPS_AUTHORITY_RETRY_DELAYS", | ||||||
|  |                     "StellaOps:Authority:Resilience:RetryDelays", | ||||||
|  |                     "StellaOps:Authority:RetryDelays", | ||||||
|  |                     "Authority:Resilience:RetryDelays", | ||||||
|  |                     "Authority:RetryDelays"); | ||||||
|  |  | ||||||
|  |                 if (!string.IsNullOrWhiteSpace(retryDelaysRaw)) | ||||||
|  |                 { | ||||||
|  |                     resilience.RetryDelays.Clear(); | ||||||
|  |                     foreach (var delay in ParseRetryDelays(retryDelaysRaw)) | ||||||
|  |                     { | ||||||
|  |                         if (delay > TimeSpan.Zero) | ||||||
|  |                         { | ||||||
|  |                             resilience.RetryDelays.Add(delay); | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 if (!resilience.AllowOfflineCacheFallback.HasValue) | ||||||
|  |                 { | ||||||
|  |                     var raw = ResolveWithFallback( | ||||||
|  |                         string.Empty, | ||||||
|  |                         configuration, | ||||||
|  |                         "STELLAOPS_AUTHORITY_ALLOW_OFFLINE_CACHE_FALLBACK", | ||||||
|  |                         "StellaOps:Authority:Resilience:AllowOfflineCacheFallback", | ||||||
|  |                         "StellaOps:Authority:AllowOfflineCacheFallback", | ||||||
|  |                         "Authority:Resilience:AllowOfflineCacheFallback", | ||||||
|  |                         "Authority:AllowOfflineCacheFallback"); | ||||||
|  |  | ||||||
|  |                     if (TryParseBoolean(raw, out var parsed)) | ||||||
|  |                     { | ||||||
|  |                         resilience.AllowOfflineCacheFallback = parsed; | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 if (!resilience.OfflineCacheTolerance.HasValue) | ||||||
|  |                 { | ||||||
|  |                     var raw = ResolveWithFallback( | ||||||
|  |                         string.Empty, | ||||||
|  |                         configuration, | ||||||
|  |                         "STELLAOPS_AUTHORITY_OFFLINE_CACHE_TOLERANCE", | ||||||
|  |                         "StellaOps:Authority:Resilience:OfflineCacheTolerance", | ||||||
|  |                         "StellaOps:Authority:OfflineCacheTolerance", | ||||||
|  |                         "Authority:Resilience:OfflineCacheTolerance", | ||||||
|  |                         "Authority:OfflineCacheTolerance"); | ||||||
|  |  | ||||||
|  |                     if (TimeSpan.TryParse(raw, CultureInfo.InvariantCulture, out var tolerance) && tolerance >= TimeSpan.Zero) | ||||||
|  |                     { | ||||||
|  |                         resilience.OfflineCacheTolerance = tolerance; | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |  | ||||||
|                 var defaultTokenCache = GetDefaultTokenCacheDirectory(); |                 var defaultTokenCache = GetDefaultTokenCacheDirectory(); | ||||||
|                 if (string.IsNullOrWhiteSpace(authority.TokenCacheDirectory)) |                 if (string.IsNullOrWhiteSpace(authority.TokenCacheDirectory)) | ||||||
|                 { |                 { | ||||||
| @@ -127,26 +205,66 @@ public static class CliBootstrapper | |||||||
|  |  | ||||||
|         return (bootstrap.Options, bootstrap.Configuration); |         return (bootstrap.Options, bootstrap.Configuration); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private static string ResolveWithFallback(string currentValue, IConfiguration configuration, params string[] keys) |     private static string ResolveWithFallback(string currentValue, IConfiguration configuration, params string[] keys) | ||||||
|     { |     { | ||||||
|         if (!string.IsNullOrWhiteSpace(currentValue)) |         if (!string.IsNullOrWhiteSpace(currentValue)) | ||||||
|         { |         { | ||||||
|             return currentValue; |             return currentValue; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         foreach (var key in keys) |         foreach (var key in keys) | ||||||
|         { |         { | ||||||
|             var value = configuration[key]; |             var value = configuration[key]; | ||||||
|             if (!string.IsNullOrWhiteSpace(value)) |             if (!string.IsNullOrWhiteSpace(value)) | ||||||
|             { |             { | ||||||
|                 return value; |                 return value; | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         return string.Empty; |         return string.Empty; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     private static bool TryParseBoolean(string value, out bool parsed) | ||||||
|  |     { | ||||||
|  |         if (string.IsNullOrWhiteSpace(value)) | ||||||
|  |         { | ||||||
|  |             parsed = default; | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (bool.TryParse(value, out parsed)) | ||||||
|  |         { | ||||||
|  |             return true; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numeric)) | ||||||
|  |         { | ||||||
|  |             parsed = numeric != 0; | ||||||
|  |             return true; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         parsed = default; | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static IEnumerable<TimeSpan> ParseRetryDelays(string raw) | ||||||
|  |     { | ||||||
|  |         if (string.IsNullOrWhiteSpace(raw)) | ||||||
|  |         { | ||||||
|  |             yield break; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var separators = new[] { ',', ';', ' ' }; | ||||||
|  |         foreach (var token in raw.Split(separators, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) | ||||||
|  |         { | ||||||
|  |             if (TimeSpan.TryParse(token, CultureInfo.InvariantCulture, out var delay) && delay > TimeSpan.Zero) | ||||||
|  |             { | ||||||
|  |                 yield return delay; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|     private static string GetDefaultTokenCacheDirectory() |     private static string GetDefaultTokenCacheDirectory() | ||||||
|     { |     { | ||||||
|         var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); |         var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); | ||||||
|   | |||||||
| @@ -1,9 +1,11 @@ | |||||||
|  | using System; | ||||||
|  | using System.Collections.Generic; | ||||||
| using StellaOps.Auth.Abstractions; | using StellaOps.Auth.Abstractions; | ||||||
|  |  | ||||||
| namespace StellaOps.Cli.Configuration; | namespace StellaOps.Cli.Configuration; | ||||||
|  |  | ||||||
| public sealed class StellaOpsCliOptions | public sealed class StellaOpsCliOptions | ||||||
| { | { | ||||||
|     public string ApiKey { get; set; } = string.Empty; |     public string ApiKey { get; set; } = string.Empty; | ||||||
|  |  | ||||||
|     public string BackendUrl { get; set; } = string.Empty; |     public string BackendUrl { get; set; } = string.Empty; | ||||||
| @@ -38,4 +40,17 @@ public sealed class StellaOpsCliAuthorityOptions | |||||||
|     public string Scope { get; set; } = StellaOpsScopes.FeedserJobsTrigger; |     public string Scope { get; set; } = StellaOpsScopes.FeedserJobsTrigger; | ||||||
|  |  | ||||||
|     public string TokenCacheDirectory { get; set; } = string.Empty; |     public string TokenCacheDirectory { get; set; } = string.Empty; | ||||||
|  |  | ||||||
|  |     public StellaOpsCliAuthorityResilienceOptions Resilience { get; set; } = new(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | public sealed class StellaOpsCliAuthorityResilienceOptions | ||||||
|  | { | ||||||
|  |     public bool? EnableRetries { get; set; } | ||||||
|  |  | ||||||
|  |     public IList<TimeSpan> RetryDelays { get; set; } = new List<TimeSpan>(); | ||||||
|  |  | ||||||
|  |     public bool? AllowOfflineCacheFallback { get; set; } | ||||||
|  |  | ||||||
|  |     public TimeSpan? OfflineCacheTolerance { get; set; } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -48,6 +48,31 @@ internal static class Program | |||||||
|                 clientOptions.DefaultScopes.Add(string.IsNullOrWhiteSpace(options.Authority.Scope) |                 clientOptions.DefaultScopes.Add(string.IsNullOrWhiteSpace(options.Authority.Scope) | ||||||
|                     ? StellaOps.Auth.Abstractions.StellaOpsScopes.FeedserJobsTrigger |                     ? StellaOps.Auth.Abstractions.StellaOpsScopes.FeedserJobsTrigger | ||||||
|                     : options.Authority.Scope); |                     : options.Authority.Scope); | ||||||
|  |  | ||||||
|  |                 var resilience = options.Authority.Resilience ?? new StellaOpsCliAuthorityResilienceOptions(); | ||||||
|  |                 clientOptions.EnableRetries = resilience.EnableRetries ?? true; | ||||||
|  |  | ||||||
|  |                 if (resilience.RetryDelays is { Count: > 0 }) | ||||||
|  |                 { | ||||||
|  |                     clientOptions.RetryDelays.Clear(); | ||||||
|  |                     foreach (var delay in resilience.RetryDelays) | ||||||
|  |                     { | ||||||
|  |                         if (delay > TimeSpan.Zero) | ||||||
|  |                         { | ||||||
|  |                             clientOptions.RetryDelays.Add(delay); | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 if (resilience.AllowOfflineCacheFallback.HasValue) | ||||||
|  |                 { | ||||||
|  |                     clientOptions.AllowOfflineCacheFallback = resilience.AllowOfflineCacheFallback.Value; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 if (resilience.OfflineCacheTolerance.HasValue && resilience.OfflineCacheTolerance.Value >= TimeSpan.Zero) | ||||||
|  |                 { | ||||||
|  |                     clientOptions.OfflineCacheTolerance = resilience.OfflineCacheTolerance.Value; | ||||||
|  |                 } | ||||||
|             }); |             }); | ||||||
|  |  | ||||||
|             var cacheDirectory = options.Authority.TokenCacheDirectory; |             var cacheDirectory = options.Authority.TokenCacheDirectory; | ||||||
|   | |||||||
| @@ -8,4 +8,7 @@ | |||||||
| |Feedser DB operations passthrough|DevEx/CLI|Backend, Feedser APIs|**DONE** – `db fetch|merge|export` trigger `/jobs/*` endpoints with parameter binding and consistent exit codes.| | |Feedser DB operations passthrough|DevEx/CLI|Backend, Feedser APIs|**DONE** – `db fetch|merge|export` trigger `/jobs/*` endpoints with parameter binding and consistent exit codes.| | ||||||
| |CLI observability & tests|QA|Command host|**DONE** – Added console logging defaults & configuration bootstrap tests; future metrics hooks tracked separately.| | |CLI observability & tests|QA|Command host|**DONE** – Added console logging defaults & configuration bootstrap tests; future metrics hooks tracked separately.| | ||||||
| |Authority auth commands|DevEx/CLI|Auth libraries|**DONE** – `auth login/logout/status` wrap the shared auth client, manage token cache, and surface status messages.| | |Authority auth commands|DevEx/CLI|Auth libraries|**DONE** – `auth login/logout/status` wrap the shared auth client, manage token cache, and surface status messages.| | ||||||
| |Document authority workflow in CLI help & quickstart|Docs/CLI|Authority auth commands|**TODO** – Capture `stellaops-cli auth` usage, env vars, and cache location in docs/09 + CLI help; assign once we resume.| | |Document authority workflow in CLI help & quickstart|Docs/CLI|Authority auth commands|**DONE (2025-10-10)** – CLI help now surfaces Authority config fields and docs/09 + docs/10 describe env vars, auth login/status flow, and cache location.| | ||||||
|  | |Authority whoami command|DevEx/CLI|Authority auth commands|**DONE (2025-10-10)** – Added `auth whoami` verb that displays subject/audience/expiry from cached tokens and handles opaque tokens gracefully.| | ||||||
|  | |Expose auth client resilience settings|DevEx/CLI|Auth libraries LIB5|**DONE (2025-10-10)** – CLI options now bind resilience knobs, `AddStellaOpsAuthClient` honours them, and tests cover env overrides.| | ||||||
|  | |Document advanced Authority tuning|Docs/CLI|Expose auth client resilience settings|**DONE (2025-10-10)** – docs/09 and docs/10 describe retry/offline settings with env examples and point to the integration guide.| | ||||||
|   | |||||||
| @@ -102,7 +102,10 @@ public class StellaOpsAuthorityOptionsTests | |||||||
|                     ["Authority:Storage:DatabaseName"] = "overrideDb", |                     ["Authority:Storage:DatabaseName"] = "overrideDb", | ||||||
|                     ["Authority:Storage:CommandTimeout"] = "00:01:30", |                     ["Authority:Storage:CommandTimeout"] = "00:01:30", | ||||||
|                     ["Authority:PluginDirectories:0"] = "/var/lib/stellaops/plugins", |                     ["Authority:PluginDirectories:0"] = "/var/lib/stellaops/plugins", | ||||||
|                     ["Authority:BypassNetworks:0"] = "127.0.0.1/32" |                     ["Authority:BypassNetworks:0"] = "127.0.0.1/32", | ||||||
|  |                     ["Authority:RateLimiting:Token:PermitLimit"] = "25", | ||||||
|  |                     ["Authority:RateLimiting:Token:Window"] = "00:00:30", | ||||||
|  |                     ["Authority:RateLimiting:Authorize:Enabled"] = "true" | ||||||
|                 }); |                 }); | ||||||
|             }; |             }; | ||||||
|         }); |         }); | ||||||
| @@ -118,5 +121,24 @@ public class StellaOpsAuthorityOptionsTests | |||||||
|         Assert.Equal("mongodb://example/stellaops", options.Storage.ConnectionString); |         Assert.Equal("mongodb://example/stellaops", options.Storage.ConnectionString); | ||||||
|         Assert.Equal("overrideDb", options.Storage.DatabaseName); |         Assert.Equal("overrideDb", options.Storage.DatabaseName); | ||||||
|         Assert.Equal(TimeSpan.FromMinutes(1.5), options.Storage.CommandTimeout); |         Assert.Equal(TimeSpan.FromMinutes(1.5), options.Storage.CommandTimeout); | ||||||
|  |         Assert.Equal(25, options.RateLimiting.Token.PermitLimit); | ||||||
|  |         Assert.Equal(TimeSpan.FromSeconds(30), options.RateLimiting.Token.Window); | ||||||
|  |         Assert.True(options.RateLimiting.Authorize.Enabled); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [Fact] | ||||||
|  |     public void Validate_Throws_When_RateLimitingInvalid() | ||||||
|  |     { | ||||||
|  |         var options = new StellaOpsAuthorityOptions | ||||||
|  |         { | ||||||
|  |             Issuer = new Uri("https://authority.stella-ops.test"), | ||||||
|  |             SchemaVersion = 1 | ||||||
|  |         }; | ||||||
|  |         options.Storage.ConnectionString = "mongodb://localhost:27017/authority"; | ||||||
|  |         options.RateLimiting.Token.PermitLimit = 0; | ||||||
|  |  | ||||||
|  |         var exception = Assert.Throws<InvalidOperationException>(() => options.Validate()); | ||||||
|  |  | ||||||
|  |         Assert.Contains("permitLimit", exception.Message, StringComparison.OrdinalIgnoreCase); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -13,6 +13,7 @@ | |||||||
|     <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" /> |     <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" /> | ||||||
|     <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" /> |     <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" /> | ||||||
|     <PackageReference Include="NetEscapades.Configuration.Yaml" Version="2.1.0" /> |     <PackageReference Include="NetEscapades.Configuration.Yaml" Version="2.1.0" /> | ||||||
|  |     <PackageReference Include="System.Threading.RateLimiting" Version="8.0.0" /> | ||||||
|   </ItemGroup> |   </ItemGroup> | ||||||
|  |  | ||||||
|   <ItemGroup> |   <ItemGroup> | ||||||
|   | |||||||
| @@ -2,6 +2,7 @@ using System; | |||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using System.IO; | using System.IO; | ||||||
| using System.Linq; | using System.Linq; | ||||||
|  | using System.Threading.RateLimiting; | ||||||
| using StellaOps.Authority.Plugins.Abstractions; | using StellaOps.Authority.Plugins.Abstractions; | ||||||
|  |  | ||||||
| namespace StellaOps.Configuration; | namespace StellaOps.Configuration; | ||||||
| @@ -74,6 +75,11 @@ public sealed class StellaOpsAuthorityOptions | |||||||
|     /// </summary> |     /// </summary> | ||||||
|     public AuthorityPluginSettings Plugins { get; } = new(); |     public AuthorityPluginSettings Plugins { get; } = new(); | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Rate limiting configuration applied to Authority endpoints. | ||||||
|  |     /// </summary> | ||||||
|  |     public AuthorityRateLimitingOptions RateLimiting { get; } = new(); | ||||||
|  |  | ||||||
|     /// <summary> |     /// <summary> | ||||||
|     /// Validates configured values and normalises collections. |     /// Validates configured values and normalises collections. | ||||||
|     /// </summary> |     /// </summary> | ||||||
| @@ -109,6 +115,7 @@ public sealed class StellaOpsAuthorityOptions | |||||||
|         NormaliseList(pluginDirectories); |         NormaliseList(pluginDirectories); | ||||||
|         NormaliseList(bypassNetworks); |         NormaliseList(bypassNetworks); | ||||||
|  |  | ||||||
|  |         RateLimiting.Validate(); | ||||||
|         Plugins.NormalizeAndValidate(); |         Plugins.NormalizeAndValidate(); | ||||||
|         Storage.Validate(); |         Storage.Validate(); | ||||||
|         Bootstrap.Validate(); |         Bootstrap.Validate(); | ||||||
| @@ -158,6 +165,82 @@ public sealed class StellaOpsAuthorityOptions | |||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | public sealed class AuthorityRateLimitingOptions | ||||||
|  | { | ||||||
|  |     public AuthorityRateLimitingOptions() | ||||||
|  |     { | ||||||
|  |         Token = new AuthorityEndpointRateLimitOptions { PermitLimit = 30 }; | ||||||
|  |         Authorize = new AuthorityEndpointRateLimitOptions { PermitLimit = 60 }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Rate limiting configuration applied to the /token endpoint. | ||||||
|  |     /// </summary> | ||||||
|  |     public AuthorityEndpointRateLimitOptions Token { get; } | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Rate limiting configuration applied to the /authorize endpoint. | ||||||
|  |     /// </summary> | ||||||
|  |     public AuthorityEndpointRateLimitOptions Authorize { get; } | ||||||
|  |  | ||||||
|  |     internal void Validate() | ||||||
|  |     { | ||||||
|  |         Token.Validate(nameof(Token)); | ||||||
|  |         Authorize.Validate(nameof(Authorize)); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | public sealed class AuthorityEndpointRateLimitOptions | ||||||
|  | { | ||||||
|  |     /// <summary> | ||||||
|  |     /// Gets or sets a value indicating whether rate limiting is enabled for the endpoint. | ||||||
|  |     /// </summary> | ||||||
|  |     public bool Enabled { get; set; } = true; | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Maximum number of requests allowed within the configured window. | ||||||
|  |     /// </summary> | ||||||
|  |     public int PermitLimit { get; set; } = 60; | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Size of the fixed window applied to the rate limiter. | ||||||
|  |     /// </summary> | ||||||
|  |     public TimeSpan Window { get; set; } = TimeSpan.FromMinutes(1); | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Maximum number of queued requests awaiting permits. | ||||||
|  |     /// </summary> | ||||||
|  |     public int QueueLimit { get; set; } = 0; | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Ordering strategy for queued requests. | ||||||
|  |     /// </summary> | ||||||
|  |     public QueueProcessingOrder QueueProcessingOrder { get; set; } = QueueProcessingOrder.OldestFirst; | ||||||
|  |  | ||||||
|  |     internal void Validate(string name) | ||||||
|  |     { | ||||||
|  |         if (!Enabled) | ||||||
|  |         { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (PermitLimit <= 0) | ||||||
|  |         { | ||||||
|  |             throw new InvalidOperationException($"Authority rate limiting '{name}' requires permitLimit to be greater than zero."); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (QueueLimit < 0) | ||||||
|  |         { | ||||||
|  |             throw new InvalidOperationException($"Authority rate limiting '{name}' queueLimit cannot be negative."); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (Window <= TimeSpan.Zero || Window > TimeSpan.FromHours(1)) | ||||||
|  |         { | ||||||
|  |             throw new InvalidOperationException($"Authority rate limiting '{name}' window must be greater than zero and no more than one hour."); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
| public sealed class AuthorityStorageOptions | public sealed class AuthorityStorageOptions | ||||||
| { | { | ||||||
|     /// <summary> |     /// <summary> | ||||||
|   | |||||||
							
								
								
									
										24
									
								
								src/StellaOps.Cryptography.Tests/PasswordHashOptionsTests.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/StellaOps.Cryptography.Tests/PasswordHashOptionsTests.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | |||||||
|  | using StellaOps.Cryptography; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Cryptography.Tests; | ||||||
|  |  | ||||||
|  | public class PasswordHashOptionsTests | ||||||
|  | { | ||||||
|  |     [Fact] | ||||||
|  |     public void Validate_DoesNotThrow_ForDefaults() | ||||||
|  |     { | ||||||
|  |         var options = new PasswordHashOptions(); | ||||||
|  |         options.Validate(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [Fact] | ||||||
|  |     public void Validate_Throws_WhenMemoryInvalid() | ||||||
|  |     { | ||||||
|  |         var options = new PasswordHashOptions | ||||||
|  |         { | ||||||
|  |             MemorySizeInKib = 0 | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         Assert.Throws<InvalidOperationException>(options.Validate); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,11 @@ | |||||||
|  | <Project Sdk="Microsoft.NET.Sdk"> | ||||||
|  |   <PropertyGroup> | ||||||
|  |     <TargetFramework>net10.0</TargetFramework> | ||||||
|  |     <Nullable>enable</Nullable> | ||||||
|  |     <ImplicitUsings>enable</ImplicitUsings> | ||||||
|  |     <IsPackable>false</IsPackable> | ||||||
|  |   </PropertyGroup> | ||||||
|  |   <ItemGroup> | ||||||
|  |     <ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" /> | ||||||
|  |   </ItemGroup> | ||||||
|  | </Project> | ||||||
							
								
								
									
										21
									
								
								src/StellaOps.Cryptography/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/StellaOps.Cryptography/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | |||||||
|  | # Team 8 — Security Guild (Authority & Shared Crypto) | ||||||
|  |  | ||||||
|  | ## Role | ||||||
|  |  | ||||||
|  | Team 8 owns the end-to-end security posture for StellaOps Authority and its consumers. That includes password hashing policy, audit/event hygiene, rate-limit & lockout rules, revocation distribution, and sovereign cryptography abstractions that allow alternative algorithm suites (e.g., GOST) without touching feature code. | ||||||
|  |  | ||||||
|  | ## Operational Boundaries | ||||||
|  |  | ||||||
|  | - Primary workspace: `src/StellaOps.Cryptography`, `src/StellaOps.Authority.Plugin.Standard`, `src/StellaOps.Authority.Storage.Mongo`, and Authority host (`src/StellaOps.Authority/StellaOps.Authority`).   | ||||||
|  | - Coordinate cross-module changes via TASKS.md updates and PR descriptions.   | ||||||
|  | - Never bypass deterministic behaviour (sorted keys, stable timestamps).   | ||||||
|  | - Tests live alongside owning projects (`*.Tests`). Extend goldens instead of rewriting. | ||||||
|  |  | ||||||
|  | ## Expectations | ||||||
|  |  | ||||||
|  | - Default to Argon2id (Konscious) for password hashing; PBKDF2 only for legacy verification with transparent rehash on success.   | ||||||
|  | - Emit structured security events with minimal PII and clear correlation IDs.   | ||||||
|  | - Rate-limit `/token` and bootstrap endpoints once CORE8 hooks are available.   | ||||||
|  | - Deliver offline revocation bundles signed with detached JWS and provide a verification script.   | ||||||
|  | - Maintain `docs/security/authority-threat-model.md` and ensure mitigations are tracked.   | ||||||
|  | - All crypto consumption flows through `StellaOps.Cryptography` abstractions to enable sovereign crypto providers. | ||||||
							
								
								
									
										44
									
								
								src/StellaOps.Cryptography/CryptoProvider.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								src/StellaOps.Cryptography/CryptoProvider.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,44 @@ | |||||||
|  | using System.Collections.Generic; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Cryptography; | ||||||
|  |  | ||||||
|  | /// <summary> | ||||||
|  | /// High-level cryptographic capabilities supported by StellaOps providers. | ||||||
|  | /// </summary> | ||||||
|  | public enum CryptoCapability | ||||||
|  | { | ||||||
|  |     PasswordHashing, | ||||||
|  |     Signing, | ||||||
|  |     Verification, | ||||||
|  |     SymmetricEncryption, | ||||||
|  |     KeyDerivation | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// <summary> | ||||||
|  | /// Identifies a stored key or certificate handle. | ||||||
|  | /// </summary> | ||||||
|  | public sealed record CryptoKeyReference(string KeyId, string? ProviderHint = null); | ||||||
|  |  | ||||||
|  | /// <summary> | ||||||
|  | /// Contract implemented by crypto providers (BCL, CryptoPro, OpenSSL, etc.). | ||||||
|  | /// </summary> | ||||||
|  | public interface ICryptoProvider | ||||||
|  | { | ||||||
|  |     string Name { get; } | ||||||
|  |  | ||||||
|  |     bool Supports(CryptoCapability capability, string algorithmId); | ||||||
|  |  | ||||||
|  |     IPasswordHasher GetPasswordHasher(string algorithmId); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// <summary> | ||||||
|  | /// Registry managing provider discovery and policy selection. | ||||||
|  | /// </summary> | ||||||
|  | public interface ICryptoProviderRegistry | ||||||
|  | { | ||||||
|  |     IReadOnlyCollection<ICryptoProvider> Providers { get; } | ||||||
|  |  | ||||||
|  |     bool TryResolve(string preferredProvider, out ICryptoProvider provider); | ||||||
|  |  | ||||||
|  |     ICryptoProvider ResolveOrThrow(CryptoCapability capability, string algorithmId); | ||||||
|  | } | ||||||
							
								
								
									
										81
									
								
								src/StellaOps.Cryptography/PasswordHashing.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								src/StellaOps.Cryptography/PasswordHashing.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,81 @@ | |||||||
|  | using System; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Cryptography; | ||||||
|  |  | ||||||
|  | /// <summary> | ||||||
|  | /// Supported password hashing algorithms. | ||||||
|  | /// </summary> | ||||||
|  | public enum PasswordHashAlgorithm | ||||||
|  | { | ||||||
|  |     Argon2id, | ||||||
|  |     Pbkdf2 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// <summary> | ||||||
|  | /// Options describing password hashing requirements. | ||||||
|  | /// Values follow OWASP baseline guidance by default. | ||||||
|  | /// </summary> | ||||||
|  | public sealed record PasswordHashOptions | ||||||
|  | { | ||||||
|  |     /// <summary> | ||||||
|  |     /// Algorithm to use when hashing new passwords. | ||||||
|  |     /// </summary> | ||||||
|  |     public PasswordHashAlgorithm Algorithm { get; init; } = PasswordHashAlgorithm.Argon2id; | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Memory cost in KiB (default 19 MiB). | ||||||
|  |     /// </summary> | ||||||
|  |     public int MemorySizeInKib { get; init; } = 19 * 1024; | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Iteration count / time cost. | ||||||
|  |     /// </summary> | ||||||
|  |     public int Iterations { get; init; } = 2; | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Parallelism / degree of concurrency. | ||||||
|  |     /// </summary> | ||||||
|  |     public int Parallelism { get; init; } = 1; | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Validates the option values and throws when invalid. | ||||||
|  |     /// </summary> | ||||||
|  |     public void Validate() | ||||||
|  |     { | ||||||
|  |         if (MemorySizeInKib <= 0) | ||||||
|  |         { | ||||||
|  |             throw new InvalidOperationException("Password hashing memory cost must be greater than zero."); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (Iterations <= 0) | ||||||
|  |         { | ||||||
|  |             throw new InvalidOperationException("Password hashing iteration count must be greater than zero."); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (Parallelism <= 0) | ||||||
|  |         { | ||||||
|  |             throw new InvalidOperationException("Password hashing parallelism must be greater than zero."); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /// <summary> | ||||||
|  | /// Abstraction for password hashing implementations. | ||||||
|  | /// </summary> | ||||||
|  | public interface IPasswordHasher | ||||||
|  | { | ||||||
|  |     /// <summary> | ||||||
|  |     /// Produces an encoded hash for the supplied password. | ||||||
|  |     /// </summary> | ||||||
|  |     string Hash(string password, PasswordHashOptions options); | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Verifies the supplied password against a stored hash. | ||||||
|  |     /// </summary> | ||||||
|  |     bool Verify(string password, string encodedHash); | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Detects when an existing encoded hash no longer satisfies the desired options. | ||||||
|  |     /// </summary> | ||||||
|  |     bool NeedsRehash(string encodedHash, PasswordHashOptions desired); | ||||||
|  | } | ||||||
							
								
								
									
										9
									
								
								src/StellaOps.Cryptography/StellaOps.Cryptography.csproj
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/StellaOps.Cryptography/StellaOps.Cryptography.csproj
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | |||||||
|  | <Project Sdk="Microsoft.NET.Sdk"> | ||||||
|  |   <PropertyGroup> | ||||||
|  |     <TargetFramework>net10.0</TargetFramework> | ||||||
|  |     <LangVersion>preview</LangVersion> | ||||||
|  |     <ImplicitUsings>enable</ImplicitUsings> | ||||||
|  |     <Nullable>enable</Nullable> | ||||||
|  |     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||||
|  |   </PropertyGroup> | ||||||
|  | </Project> | ||||||
							
								
								
									
										25
									
								
								src/StellaOps.Cryptography/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								src/StellaOps.Cryptography/TASKS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | |||||||
|  | # Team 8 — Security Guild Task Board (UTC 2025-10-10) | ||||||
|  |  | ||||||
|  | | ID | Status | Owner | Description | Dependencies | Exit Criteria | | ||||||
|  | |----|--------|-------|-------------|--------------|---------------| | ||||||
|  | | SEC1.A | TODO | Security Guild | Introduce `Argon2idPasswordHasher` backed by Konscious defaults. Wire options into `StandardPluginOptions` (`PasswordHashOptions`) and `StellaOpsAuthorityOptions.Security.PasswordHashing`. | PLG3, CORE3 | ✅ Hashes emit PHC string `$argon2id$v=19$m=19456,t=2,p=1$...`; ✅ `NeedsRehash` promotes PBKDF2 → Argon2; ✅ Integration tests cover tamper, legacy rehash, perf p95 < 250 ms. | | ||||||
|  | | SEC1.B | TODO | Security Guild | Add compile-time switch to enable libsodium/Core variants later (`STELLAOPS_CRYPTO_SODIUM`). Document build variable. | SEC1.A | ✅ Conditional compilation path compiles; ✅ README snippet in `docs/security/password-hashing.md`. | | ||||||
|  | | SEC2.A | TODO | Security Guild + Core | Define audit event contract (`AuthEventRecord`) with subject/client/scope/IP/outcome/correlationId and PII tags. | CORE5–CORE7 | ✅ Contract shipped in `StellaOps.Cryptography` (or shared abstractions); ✅ Docs in `docs/security/audit-events.md`. | | ||||||
|  | | SEC2.B | TODO | Security Guild | Emit audit records from OpenIddict handlers (password + client creds) and bootstrap APIs. Persist via `IAuthorityLoginAttemptStore`. | SEC2.A | ✅ Tests assert three flows (success/failure/lockout); ✅ Serilog output contains correlationId + PII tagging; ✅ Mongo store holds summary rows. | | ||||||
|  | | SEC3.A | BLOCKED (CORE8) | Security Guild + Core | Configure ASP.NET rate limiter (`AddRateLimiter`) with fixed-window policy keyed by IP + `client_id`. Apply to `/token` and `/internal/*`. | CORE8 completion | ✅ Middleware active; ✅ Configurable limits via options; ✅ Integration test hits 429. | | ||||||
|  | | SEC3.B | TODO | Security Guild | Document lockout + rate-limit tuning guidance and escalation thresholds. | SEC3.A | ✅ Section in `docs/security/rate-limits.md`; ✅ Includes SOC alert recommendations. | | ||||||
|  | | SEC4.A | TODO | Security Guild + DevOps | Define revocation JSON schema (`revocation_bundle.schema.json`) and detached JWS workflow. | CORE9, OPS3 | ✅ Schema + sample committed; ✅ CLI command `stellaops auth revoke export` scaffolded with acceptance tests; ✅ Verification script + docs. | | ||||||
|  | | SEC4.B | TODO | Security Guild | Integrate signing keys with crypto provider abstraction (initially ES256 via BCL). | SEC4.A, D5 | ✅ `ICryptoProvider.GetSigner` stub + default BCL signer; ✅ Unit tests verifying signature roundtrip. | | ||||||
|  | | SEC5.A | TODO | Security Guild | Author STRIDE threat model (`docs/security/authority-threat-model.md`) covering token, bootstrap, revocation, CLI, plugin surfaces. | All SEC1–SEC4 in progress | ✅ DFDs + trust boundaries drawn; ✅ Risk table with owners/actions; ✅ Follow-up backlog issues created. | | ||||||
|  | | D5.A | TODO | Security Guild | Flesh out `StellaOps.Cryptography` provider registry, policy, and DI helpers enabling sovereign crypto selection. | SEC1.A, SEC4.B | ✅ `ICryptoProviderRegistry` implementation with provider selection rules; ✅ `StellaOps.Cryptography.DependencyInjection` extensions; ✅ Tests covering fallback ordering. | | ||||||
|  |  | ||||||
|  | ## Notes | ||||||
|  | - Target Argon2 parameters follow OWASP Cheat Sheet (memory ≈ 19 MiB, iterations 2, parallelism 1). Allow overrides via configuration. | ||||||
|  | - When CORE8 lands, pair with Team 2 to expose request context information required by the rate limiter (client_id enrichment). | ||||||
|  | - Revocation bundle must be consumable offline; include issue timestamp, signing key metadata, and reasons. | ||||||
|  | - All crypto usage in Authority code should funnel through the new abstractions (`ICryptoProvider`), enabling future CryptoPro/OpenSSL providers. | ||||||
|  |  | ||||||
|  | ## Done Definition | ||||||
|  | - Code merges include unit/integration tests and documentation updates. | ||||||
|  | - `TASKS.md` status transitions (TODO → DOING → DONE/BLOCKED) must happen in the same PR as the work. | ||||||
|  | - Prior to marking DONE: run `dotnet test` for touched solutions and attach excerpt to PR description. | ||||||
| @@ -7,7 +7,8 @@ | |||||||
| |Debian EVR comparer plus tests|BE-Merge (Distro WG)|Debian fixtures|DONE – DebianEvr comparer mirrors dpkg ordering with tilde/epoch handling and unit coverage.| | |Debian EVR comparer plus tests|BE-Merge (Distro WG)|Debian fixtures|DONE – DebianEvr comparer mirrors dpkg ordering with tilde/epoch handling and unit coverage.| | ||||||
| |SemVer range resolver plus tests|BE-Merge (OSS WG)|OSV/GHSA fixtures|DONE – SemanticVersionRangeResolver covers introduced/fixed/lastAffected semantics with SemVer ordering tests.| | |SemVer range resolver plus tests|BE-Merge (OSS WG)|OSV/GHSA fixtures|DONE – SemanticVersionRangeResolver covers introduced/fixed/lastAffected semantics with SemVer ordering tests.| | ||||||
| |Canonical hash and merge_event writer|BE-Merge|Models, Storage.Mongo|DONE – Hash calculator + MergeEventWriter compute canonical SHA-256 digests and persist merge events.| | |Canonical hash and merge_event writer|BE-Merge|Models, Storage.Mongo|DONE – Hash calculator + MergeEventWriter compute canonical SHA-256 digests and persist merge events.| | ||||||
| |Conflict detection and metrics|BE-Merge|Core|**DONE** – merge meters emit override/conflict counters and structured audits (`AdvisoryPrecedenceMerger`).| | |Conflict detection and metrics|BE-Merge|Core|**DONE** – merge meters emit override/conflict counters and structured audits (`AdvisoryPrecedenceMerger`).| | ||||||
| |End-to-end determinism test|QA|Merge, key connectors|**DONE** – `MergePrecedenceIntegrationTests.MergePipeline_IsDeterministicAcrossRuns` guards determinism.| | |End-to-end determinism test|QA|Merge, key connectors|**DONE** – `MergePrecedenceIntegrationTests.MergePipeline_IsDeterministicAcrossRuns` guards determinism.| | ||||||
| |Override audit logging|BE-Merge|Observability|DONE – override audits now emit structured logs plus bounded-tag metrics suitable for prod telemetry.| | |Override audit logging|BE-Merge|Observability|DONE – override audits now emit structured logs plus bounded-tag metrics suitable for prod telemetry.| | ||||||
| |Configurable precedence table|BE-Merge|Architecture|DONE – precedence options bind via feedser:merge:precedence:ranks with docs/tests covering operator workflow.| | |Configurable precedence table|BE-Merge|Architecture|DONE – precedence options bind via feedser:merge:precedence:ranks with docs/tests covering operator workflow.| | ||||||
|  | |Range primitives backlog|BE-Merge|Connector WGs|**DOING** – Coordinate remaining connectors (`Acsc`, `Cccs`, `CertBund`, `CertCc`, `Cve`, `Ghsa`, `Ics.Cisa`, `Kisa`, `Ru.Bdu`, `Ru.Nkcki`, `Vndr.Apple`, `Vndr.Cisco`, `Vndr.Msrc`) to emit canonical RangePrimitives with provenance tags; track progress/fixtures here.|  | ||||||
|   | |||||||
| @@ -11,5 +11,6 @@ | |||||||
| |Golden mapping fixtures|QA|Fixtures|**DONE** – fixture validation test now snapshots RHSA-2025:0001/0002/0003 with env-driven regeneration.| | |Golden mapping fixtures|QA|Fixtures|**DONE** – fixture validation test now snapshots RHSA-2025:0001/0002/0003 with env-driven regeneration.| | ||||||
| |Job scheduling defaults for source:redhat tasks|BE-Core|JobScheduler|**DONE** – Cron windows + per-job timeouts defined for fetch/parse/map.|  | |Job scheduling defaults for source:redhat tasks|BE-Core|JobScheduler|**DONE** – Cron windows + per-job timeouts defined for fetch/parse/map.|  | ||||||
| |Express unaffected/investigation statuses without overloading range fields|BE-Conn-RH|Models|**DONE** – Introduced AffectedPackageStatus collection and updated mapper/tests.| | |Express unaffected/investigation statuses without overloading range fields|BE-Conn-RH|Models|**DONE** – Introduced AffectedPackageStatus collection and updated mapper/tests.| | ||||||
| |Reference dedupe & ordering in mapper|BE-Conn-RH|Models|DONE – mapper consolidates by URL, merges metadata, deterministic ordering validated in tests.| | |Reference dedupe & ordering in mapper|BE-Conn-RH|Models|DONE – mapper consolidates by URL, merges metadata, deterministic ordering validated in tests.| | ||||||
| |Hydra summary fetch through SourceFetchService|BE-Conn-RH|Source.Common|DONE – summary pages now fetched via SourceFetchService with cache + conditional headers.| | |Hydra summary fetch through SourceFetchService|BE-Conn-RH|Source.Common|DONE – summary pages now fetched via SourceFetchService with cache + conditional headers.| | ||||||
|  | |Fixture validation sweep|QA|Testing|**DOING (2025-10-10)** – Regenerate RHSA fixtures once mapper fixes land, review snapshot diffs, and update docs; blocked by outstanding range provenance patches.|  | ||||||
|   | |||||||
| @@ -0,0 +1,56 @@ | |||||||
|  | using System; | ||||||
|  | using System.IO; | ||||||
|  | using StellaOps.Feedser.WebService.Options; | ||||||
|  | using Xunit; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Feedser.WebService.Tests; | ||||||
|  |  | ||||||
|  | public sealed class FeedserOptionsPostConfigureTests | ||||||
|  | { | ||||||
|  |     [Fact] | ||||||
|  |     public void Apply_LoadsClientSecretFromRelativeFile() | ||||||
|  |     { | ||||||
|  |         var tempDirectory = Directory.CreateTempSubdirectory(); | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             var secretPath = Path.Combine(tempDirectory.FullName, "authority.secret"); | ||||||
|  |             File.WriteAllText(secretPath, " feedser-secret "); | ||||||
|  |  | ||||||
|  |             var options = new FeedserOptions | ||||||
|  |             { | ||||||
|  |                 Authority = new FeedserOptions.AuthorityOptions | ||||||
|  |                 { | ||||||
|  |                     ClientSecretFile = "authority.secret" | ||||||
|  |                 } | ||||||
|  |             }; | ||||||
|  |  | ||||||
|  |             FeedserOptionsPostConfigure.Apply(options, tempDirectory.FullName); | ||||||
|  |  | ||||||
|  |             Assert.Equal("feedser-secret", options.Authority.ClientSecret); | ||||||
|  |         } | ||||||
|  |         finally | ||||||
|  |         { | ||||||
|  |             if (Directory.Exists(tempDirectory.FullName)) | ||||||
|  |             { | ||||||
|  |                 Directory.Delete(tempDirectory.FullName, recursive: true); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [Fact] | ||||||
|  |     public void Apply_ThrowsWhenSecretFileMissing() | ||||||
|  |     { | ||||||
|  |         var options = new FeedserOptions | ||||||
|  |         { | ||||||
|  |             Authority = new FeedserOptions.AuthorityOptions | ||||||
|  |             { | ||||||
|  |                 ClientSecretFile = "missing.secret" | ||||||
|  |             } | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         var exception = Assert.Throws<InvalidOperationException>(() => | ||||||
|  |             FeedserOptionsPostConfigure.Apply(options, AppContext.BaseDirectory)); | ||||||
|  |  | ||||||
|  |         Assert.Contains("Authority client secret file", exception.Message); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,12 +1,16 @@ | |||||||
| using System; | using System; | ||||||
| using System.Collections.Generic; | using System.Collections.Generic; | ||||||
| using System.Linq; | using System.Linq; | ||||||
| using System.Net; | using System.Net; | ||||||
| using System.Net.Http.Json; | using System.Net.Http.Json; | ||||||
| using Microsoft.AspNetCore.Hosting; | using Microsoft.AspNetCore.Builder; | ||||||
| using Microsoft.AspNetCore.Mvc.Testing; | using Microsoft.AspNetCore.Hosting; | ||||||
|  | using Microsoft.AspNetCore.Mvc.Testing; | ||||||
|  | using Microsoft.AspNetCore.TestHost; | ||||||
| using Microsoft.Extensions.Configuration; | using Microsoft.Extensions.Configuration; | ||||||
| using Microsoft.Extensions.DependencyInjection; | using Microsoft.Extensions.DependencyInjection; | ||||||
|  | using Microsoft.Extensions.Logging; | ||||||
|  | using Microsoft.Extensions.Options; | ||||||
| using Mongo2Go; | using Mongo2Go; | ||||||
| using StellaOps.Feedser.Core.Jobs; | using StellaOps.Feedser.Core.Jobs; | ||||||
| using StellaOps.Feedser.WebService.Jobs; | using StellaOps.Feedser.WebService.Jobs; | ||||||
| @@ -214,27 +218,103 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime | |||||||
|     [Fact] |     [Fact] | ||||||
|     public async Task JobsEndpointsAllowBypassWhenAuthorityEnabled() |     public async Task JobsEndpointsAllowBypassWhenAuthorityEnabled() | ||||||
|     { |     { | ||||||
|         using var factory = new FeedserApplicationFactory(_runner.ConnectionString, authority => |         var environment = new Dictionary<string, string?> | ||||||
|         { |         { | ||||||
|             authority.Enabled = true; |             ["FEEDSER_AUTHORITY__ENABLED"] = "true", | ||||||
|             authority.Issuer = "https://authority.example"; |             ["FEEDSER_AUTHORITY__ALLOWANONYMOUSFALLBACK"] = "false", | ||||||
|             authority.RequireHttpsMetadata = false; |             ["FEEDSER_AUTHORITY__ISSUER"] = "https://authority.example", | ||||||
|             authority.Audiences.Clear(); |             ["FEEDSER_AUTHORITY__REQUIREHTTPSMETADATA"] = "false", | ||||||
|             authority.Audiences.Add("api://feedser"); |             ["FEEDSER_AUTHORITY__AUDIENCES__0"] = "api://feedser", | ||||||
|             authority.RequiredScopes.Clear(); |             ["FEEDSER_AUTHORITY__REQUIREDSCOPES__0"] = StellaOpsScopes.FeedserJobsTrigger, | ||||||
|             authority.RequiredScopes.Add(StellaOpsScopes.FeedserJobsTrigger); |             ["FEEDSER_AUTHORITY__BYPASSNETWORKS__0"] = "127.0.0.1/32", | ||||||
|             authority.BypassNetworks.Clear(); |             ["FEEDSER_AUTHORITY__BYPASSNETWORKS__1"] = "::1/128", | ||||||
|             authority.BypassNetworks.Add("127.0.0.1/32"); |             ["FEEDSER_AUTHORITY__CLIENTID"] = "feedser-jobs", | ||||||
|             authority.BypassNetworks.Add("::1/128"); |             ["FEEDSER_AUTHORITY__CLIENTSECRET"] = "test-secret", | ||||||
|         }); |             ["FEEDSER_AUTHORITY__CLIENTSCOPES__0"] = StellaOpsScopes.FeedserJobsTrigger, | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         using var factory = new FeedserApplicationFactory( | ||||||
|  |             _runner.ConnectionString, | ||||||
|  |             authority => | ||||||
|  |             { | ||||||
|  |                 authority.Enabled = true; | ||||||
|  |                 authority.AllowAnonymousFallback = false; | ||||||
|  |                 authority.Issuer = "https://authority.example"; | ||||||
|  |                 authority.RequireHttpsMetadata = false; | ||||||
|  |                 authority.Audiences.Clear(); | ||||||
|  |                 authority.Audiences.Add("api://feedser"); | ||||||
|  |                 authority.RequiredScopes.Clear(); | ||||||
|  |                 authority.RequiredScopes.Add(StellaOpsScopes.FeedserJobsTrigger); | ||||||
|  |                 authority.BypassNetworks.Clear(); | ||||||
|  |                 authority.BypassNetworks.Add("127.0.0.1/32"); | ||||||
|  |                 authority.BypassNetworks.Add("::1/128"); | ||||||
|  |                 authority.ClientId = "feedser-jobs"; | ||||||
|  |                 authority.ClientSecret = "test-secret"; | ||||||
|  |             }, | ||||||
|  |             environment); | ||||||
|  |  | ||||||
|         var handler = factory.Services.GetRequiredService<StubJobCoordinator>(); |         var handler = factory.Services.GetRequiredService<StubJobCoordinator>(); | ||||||
|         handler.Definitions = new[] { new JobDefinition("demo", typeof(DemoJob), TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(1), null, true) }; |         handler.Definitions = new[] { new JobDefinition("demo", typeof(DemoJob), TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(1), null, true) }; | ||||||
|  |  | ||||||
|         using var client = factory.CreateClient(); |         using var client = factory.CreateClient(); | ||||||
|  |         client.DefaultRequestHeaders.Add("X-Test-RemoteAddr", "127.0.0.1"); | ||||||
|         var response = await client.GetAsync("/jobs/definitions"); |         var response = await client.GetAsync("/jobs/definitions"); | ||||||
|  |  | ||||||
|         Assert.Equal(HttpStatusCode.OK, response.StatusCode); |         Assert.Equal(HttpStatusCode.OK, response.StatusCode); | ||||||
|  |  | ||||||
|  |         var auditLogs = factory.LoggerProvider.Snapshot("Feedser.Authorization.Audit"); | ||||||
|  |         var bypassLog = Assert.Single(auditLogs, entry => entry.TryGetState("Bypass", out var state) && state is bool flag && flag); | ||||||
|  |         Assert.True(bypassLog.TryGetState("RemoteAddress", out var remoteObj) && string.Equals(remoteObj?.ToString(), "127.0.0.1", StringComparison.Ordinal)); | ||||||
|  |         Assert.True(bypassLog.TryGetState("StatusCode", out var statusObj) && Convert.ToInt32(statusObj) == (int)HttpStatusCode.OK); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     [Fact] | ||||||
|  |     public async Task JobsEndpointsRequireAuthWhenFallbackDisabled() | ||||||
|  |     { | ||||||
|  |         var enforcementEnvironment = new Dictionary<string, string?> | ||||||
|  |         { | ||||||
|  |             ["FEEDSER_AUTHORITY__ENABLED"] = "true", | ||||||
|  |             ["FEEDSER_AUTHORITY__ALLOWANONYMOUSFALLBACK"] = "false", | ||||||
|  |             ["FEEDSER_AUTHORITY__ISSUER"] = "https://authority.example", | ||||||
|  |             ["FEEDSER_AUTHORITY__REQUIREHTTPSMETADATA"] = "false", | ||||||
|  |             ["FEEDSER_AUTHORITY__AUDIENCES__0"] = "api://feedser", | ||||||
|  |             ["FEEDSER_AUTHORITY__REQUIREDSCOPES__0"] = StellaOpsScopes.FeedserJobsTrigger, | ||||||
|  |             ["FEEDSER_AUTHORITY__CLIENTID"] = "feedser-jobs", | ||||||
|  |             ["FEEDSER_AUTHORITY__CLIENTSECRET"] = "test-secret", | ||||||
|  |             ["FEEDSER_AUTHORITY__CLIENTSCOPES__0"] = StellaOpsScopes.FeedserJobsTrigger, | ||||||
|  |         }; | ||||||
|  |  | ||||||
|  |         using var factory = new FeedserApplicationFactory( | ||||||
|  |             _runner.ConnectionString, | ||||||
|  |             authority => | ||||||
|  |             { | ||||||
|  |                 authority.Enabled = true; | ||||||
|  |                 authority.AllowAnonymousFallback = false; | ||||||
|  |                 authority.Issuer = "https://authority.example"; | ||||||
|  |                 authority.RequireHttpsMetadata = false; | ||||||
|  |                 authority.Audiences.Clear(); | ||||||
|  |                 authority.Audiences.Add("api://feedser"); | ||||||
|  |                 authority.RequiredScopes.Clear(); | ||||||
|  |                 authority.RequiredScopes.Add(StellaOpsScopes.FeedserJobsTrigger); | ||||||
|  |                 authority.BypassNetworks.Clear(); | ||||||
|  |                 authority.ClientId = "feedser-jobs"; | ||||||
|  |                 authority.ClientSecret = "test-secret"; | ||||||
|  |             }, | ||||||
|  |             enforcementEnvironment); | ||||||
|  |  | ||||||
|  |         var resolved = factory.Services.GetRequiredService<IOptions<FeedserOptions>>().Value; | ||||||
|  |         Assert.False(resolved.Authority.AllowAnonymousFallback); | ||||||
|  |  | ||||||
|  |         using var client = factory.CreateClient(); | ||||||
|  |         client.DefaultRequestHeaders.Add("X-Test-RemoteAddr", "127.0.0.1"); | ||||||
|  |         var response = await client.GetAsync("/jobs/definitions"); | ||||||
|  |  | ||||||
|  |         Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); | ||||||
|  |  | ||||||
|  |         var auditLogs = factory.LoggerProvider.Snapshot("Feedser.Authorization.Audit"); | ||||||
|  |         var enforcementLog = Assert.Single(auditLogs); | ||||||
|  |         Assert.True(enforcementLog.TryGetState("BypassAllowed", out var bypassAllowedObj) && bypassAllowedObj is bool bypassAllowed && bypassAllowed == false); | ||||||
|  |         Assert.True(enforcementLog.TryGetState("HasPrincipal", out var principalObj) && principalObj is bool hasPrincipal && hasPrincipal == false); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private sealed class FeedserApplicationFactory : WebApplicationFactory<Program> |     private sealed class FeedserApplicationFactory : WebApplicationFactory<Program> | ||||||
| @@ -248,43 +328,62 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime | |||||||
|         private readonly string? _previousTelemetryTracing; |         private readonly string? _previousTelemetryTracing; | ||||||
|         private readonly string? _previousTelemetryMetrics; |         private readonly string? _previousTelemetryMetrics; | ||||||
|         private readonly Action<FeedserOptions.AuthorityOptions>? _authorityConfigure; |         private readonly Action<FeedserOptions.AuthorityOptions>? _authorityConfigure; | ||||||
|  |         private readonly IDictionary<string, string?> _additionalPreviousEnvironment = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase); | ||||||
|  |         public CollectingLoggerProvider LoggerProvider { get; } = new(); | ||||||
|  |  | ||||||
|         public FeedserApplicationFactory(string connectionString, Action<FeedserOptions.AuthorityOptions>? authorityConfigure = null) |         public FeedserApplicationFactory( | ||||||
|  |             string connectionString, | ||||||
|  |             Action<FeedserOptions.AuthorityOptions>? authorityConfigure = null, | ||||||
|  |             IDictionary<string, string?>? environmentOverrides = null) | ||||||
|         { |         { | ||||||
|             _connectionString = connectionString; |             _connectionString = connectionString; | ||||||
|             _authorityConfigure = authorityConfigure; |             _authorityConfigure = authorityConfigure; | ||||||
|             _previousDsn = Environment.GetEnvironmentVariable("FEEDSER_STORAGE__DSN"); |             _previousDsn = Environment.GetEnvironmentVariable("FEEDSER_STORAGE__DSN"); | ||||||
|             _previousDriver = Environment.GetEnvironmentVariable("FEEDSER_STORAGE__DRIVER"); |             _previousDriver = Environment.GetEnvironmentVariable("FEEDSER_STORAGE__DRIVER"); | ||||||
|             _previousTimeout = Environment.GetEnvironmentVariable("FEEDSER_STORAGE__COMMANDTIMEOUTSECONDS"); |             _previousTimeout = Environment.GetEnvironmentVariable("FEEDSER_STORAGE__COMMANDTIMEOUTSECONDS"); | ||||||
|             _previousTelemetryEnabled = Environment.GetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLED"); |             _previousTelemetryEnabled = Environment.GetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLED"); | ||||||
|             _previousTelemetryLogging = Environment.GetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLELOGGING"); |             _previousTelemetryLogging = Environment.GetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLELOGGING"); | ||||||
|             _previousTelemetryTracing = Environment.GetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLETRACING"); |             _previousTelemetryTracing = Environment.GetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLETRACING"); | ||||||
|             _previousTelemetryMetrics = Environment.GetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLEMETRICS"); |             _previousTelemetryMetrics = Environment.GetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLEMETRICS"); | ||||||
|             Environment.SetEnvironmentVariable("FEEDSER_STORAGE__DSN", connectionString); |             Environment.SetEnvironmentVariable("FEEDSER_STORAGE__DSN", connectionString); | ||||||
|             Environment.SetEnvironmentVariable("FEEDSER_STORAGE__DRIVER", "mongo"); |             Environment.SetEnvironmentVariable("FEEDSER_STORAGE__DRIVER", "mongo"); | ||||||
|             Environment.SetEnvironmentVariable("FEEDSER_STORAGE__COMMANDTIMEOUTSECONDS", "30"); |             Environment.SetEnvironmentVariable("FEEDSER_STORAGE__COMMANDTIMEOUTSECONDS", "30"); | ||||||
|             Environment.SetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLED", "false"); |             Environment.SetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLED", "false"); | ||||||
|             Environment.SetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLELOGGING", "false"); |             Environment.SetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLELOGGING", "false"); | ||||||
|             Environment.SetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLETRACING", "false"); |             Environment.SetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLETRACING", "false"); | ||||||
|             Environment.SetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLEMETRICS", "false"); |             Environment.SetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLEMETRICS", "false"); | ||||||
|         } |             if (environmentOverrides is not null) | ||||||
|  |             { | ||||||
|         protected override void ConfigureWebHost(IWebHostBuilder builder) |                 foreach (var kvp in environmentOverrides) | ||||||
|         { |                 { | ||||||
|             builder.ConfigureAppConfiguration((context, configurationBuilder) => |                     var previous = Environment.GetEnvironmentVariable(kvp.Key); | ||||||
|             { |                     _additionalPreviousEnvironment[kvp.Key] = previous; | ||||||
|                 var settings = new Dictionary<string, string?> |                     Environment.SetEnvironmentVariable(kvp.Key, kvp.Value); | ||||||
|                 { |                 } | ||||||
|                     ["Plugins:Directory"] = Path.Combine(context.HostingEnvironment.ContentRootPath, "PluginBinaries"), |             } | ||||||
|                 }; |         } | ||||||
|  |  | ||||||
|                 configurationBuilder.AddInMemoryCollection(settings!); |         protected override void ConfigureWebHost(IWebHostBuilder builder) | ||||||
|             }); |         { | ||||||
|  |             builder.ConfigureAppConfiguration((context, configurationBuilder) => | ||||||
|             builder.ConfigureServices(services => |             { | ||||||
|             { |                 var settings = new Dictionary<string, string?> | ||||||
|                 services.AddSingleton<StubJobCoordinator>(); |                 { | ||||||
|                 services.AddSingleton<IJobCoordinator>(sp => sp.GetRequiredService<StubJobCoordinator>()); |                     ["Plugins:Directory"] = Path.Combine(context.HostingEnvironment.ContentRootPath, "PluginBinaries"), | ||||||
|  |                 }; | ||||||
|  |  | ||||||
|  |                 configurationBuilder.AddInMemoryCollection(settings!); | ||||||
|  |             }); | ||||||
|  |  | ||||||
|  |             builder.ConfigureLogging(logging => | ||||||
|  |             { | ||||||
|  |                 logging.AddProvider(LoggerProvider); | ||||||
|  |             }); | ||||||
|  |  | ||||||
|  |             builder.ConfigureServices(services => | ||||||
|  |             { | ||||||
|  |                 services.AddSingleton<StubJobCoordinator>(); | ||||||
|  |                 services.AddSingleton<IJobCoordinator>(sp => sp.GetRequiredService<StubJobCoordinator>()); | ||||||
|                 services.PostConfigure<FeedserOptions>(options => |                 services.PostConfigure<FeedserOptions>(options => | ||||||
|                 { |                 { | ||||||
|                     options.Storage.Driver = "mongo"; |                     options.Storage.Driver = "mongo"; | ||||||
| @@ -299,20 +398,176 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime | |||||||
|                     _authorityConfigure?.Invoke(options.Authority); |                     _authorityConfigure?.Invoke(options.Authority); | ||||||
|                 }); |                 }); | ||||||
|             }); |             }); | ||||||
|  |  | ||||||
|  |             builder.ConfigureTestServices(services => | ||||||
|  |             { | ||||||
|  |                 services.AddSingleton<IStartupFilter, RemoteIpStartupFilter>(); | ||||||
|  |             }); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         protected override void Dispose(bool disposing) |         protected override void Dispose(bool disposing) | ||||||
|         { |         { | ||||||
|             base.Dispose(disposing); |             base.Dispose(disposing); | ||||||
|             Environment.SetEnvironmentVariable("FEEDSER_STORAGE__DSN", _previousDsn); |             Environment.SetEnvironmentVariable("FEEDSER_STORAGE__DSN", _previousDsn); | ||||||
|             Environment.SetEnvironmentVariable("FEEDSER_STORAGE__DRIVER", _previousDriver); |             Environment.SetEnvironmentVariable("FEEDSER_STORAGE__DRIVER", _previousDriver); | ||||||
|             Environment.SetEnvironmentVariable("FEEDSER_STORAGE__COMMANDTIMEOUTSECONDS", _previousTimeout); |             Environment.SetEnvironmentVariable("FEEDSER_STORAGE__COMMANDTIMEOUTSECONDS", _previousTimeout); | ||||||
|             Environment.SetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLED", _previousTelemetryEnabled); |             Environment.SetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLED", _previousTelemetryEnabled); | ||||||
|             Environment.SetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLELOGGING", _previousTelemetryLogging); |             Environment.SetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLELOGGING", _previousTelemetryLogging); | ||||||
|             Environment.SetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLETRACING", _previousTelemetryTracing); |             Environment.SetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLETRACING", _previousTelemetryTracing); | ||||||
|             Environment.SetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLEMETRICS", _previousTelemetryMetrics); |             Environment.SetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLEMETRICS", _previousTelemetryMetrics); | ||||||
|         } |             foreach (var kvp in _additionalPreviousEnvironment) | ||||||
|     } |             { | ||||||
|  |                 Environment.SetEnvironmentVariable(kvp.Key, kvp.Value); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             LoggerProvider.Dispose(); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private sealed class RemoteIpStartupFilter : IStartupFilter | ||||||
|  |         { | ||||||
|  |             public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next) | ||||||
|  |             { | ||||||
|  |                 return app => | ||||||
|  |                 { | ||||||
|  |                     app.Use(async (context, nextMiddleware) => | ||||||
|  |                     { | ||||||
|  |                         if (context.Request.Headers.TryGetValue("X-Test-RemoteAddr", out var values) | ||||||
|  |                             && values.Count > 0 | ||||||
|  |                             && IPAddress.TryParse(values[0], out var remote)) | ||||||
|  |                         { | ||||||
|  |                             context.Connection.RemoteIpAddress = remote; | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         await nextMiddleware(); | ||||||
|  |                     }); | ||||||
|  |  | ||||||
|  |                     next(app); | ||||||
|  |                 }; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public sealed record LogEntry( | ||||||
|  |             string LoggerName, | ||||||
|  |             LogLevel Level, | ||||||
|  |             EventId EventId, | ||||||
|  |             string? Message, | ||||||
|  |             Exception? Exception, | ||||||
|  |             IReadOnlyList<KeyValuePair<string, object?>> State) | ||||||
|  |         { | ||||||
|  |             public bool TryGetState(string name, out object? value) | ||||||
|  |             { | ||||||
|  |                 foreach (var kvp in State) | ||||||
|  |                 { | ||||||
|  |                     if (string.Equals(kvp.Key, name, StringComparison.Ordinal)) | ||||||
|  |                     { | ||||||
|  |                         value = kvp.Value; | ||||||
|  |                         return true; | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 value = null; | ||||||
|  |                 return false; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         public sealed class CollectingLoggerProvider : ILoggerProvider | ||||||
|  |         { | ||||||
|  |             private readonly object syncRoot = new(); | ||||||
|  |             private readonly List<LogEntry> entries = new(); | ||||||
|  |             private bool disposed; | ||||||
|  |  | ||||||
|  |             public ILogger CreateLogger(string categoryName) => new CollectingLogger(categoryName, this); | ||||||
|  |  | ||||||
|  |             public IReadOnlyList<LogEntry> Snapshot(string loggerName) | ||||||
|  |             { | ||||||
|  |                 lock (syncRoot) | ||||||
|  |                 { | ||||||
|  |                     return entries | ||||||
|  |                         .Where(entry => string.Equals(entry.LoggerName, loggerName, StringComparison.Ordinal)) | ||||||
|  |                         .ToArray(); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             public void Dispose() | ||||||
|  |             { | ||||||
|  |                 disposed = true; | ||||||
|  |                 lock (syncRoot) | ||||||
|  |                 { | ||||||
|  |                     entries.Clear(); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             private void Append(LogEntry entry) | ||||||
|  |             { | ||||||
|  |                 if (disposed) | ||||||
|  |                 { | ||||||
|  |                     return; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 lock (syncRoot) | ||||||
|  |                 { | ||||||
|  |                     entries.Add(entry); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             private sealed class CollectingLogger : ILogger | ||||||
|  |             { | ||||||
|  |                 private readonly string categoryName; | ||||||
|  |                 private readonly CollectingLoggerProvider provider; | ||||||
|  |  | ||||||
|  |                 public CollectingLogger(string categoryName, CollectingLoggerProvider provider) | ||||||
|  |                 { | ||||||
|  |                     this.categoryName = categoryName; | ||||||
|  |                     this.provider = provider; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 public IDisposable? BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance; | ||||||
|  |  | ||||||
|  |                 public bool IsEnabled(LogLevel logLevel) => true; | ||||||
|  |  | ||||||
|  |                 public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) | ||||||
|  |                 { | ||||||
|  |                     if (formatter is null) | ||||||
|  |                     { | ||||||
|  |                         throw new ArgumentNullException(nameof(formatter)); | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     var message = formatter(state, exception); | ||||||
|  |                     var kvps = ExtractState(state); | ||||||
|  |                     var entry = new LogEntry(categoryName, logLevel, eventId, message, exception, kvps); | ||||||
|  |                     provider.Append(entry); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 private static IReadOnlyList<KeyValuePair<string, object?>> ExtractState<TState>(TState state) | ||||||
|  |                 { | ||||||
|  |                     if (state is IReadOnlyList<KeyValuePair<string, object?>> list) | ||||||
|  |                     { | ||||||
|  |                         return list; | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     if (state is IEnumerable<KeyValuePair<string, object?>> enumerable) | ||||||
|  |                     { | ||||||
|  |                         return enumerable.ToArray(); | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     if (state is null) | ||||||
|  |                     { | ||||||
|  |                         return Array.Empty<KeyValuePair<string, object?>>(); | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     return new[] { new KeyValuePair<string, object?>("State", state) }; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             private sealed class NullScope : IDisposable | ||||||
|  |             { | ||||||
|  |                 public static readonly NullScope Instance = new(); | ||||||
|  |                 public void Dispose() | ||||||
|  |                 { | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|     private sealed record HealthPayload(string Status, DateTimeOffset StartedAt, double UptimeSeconds, StoragePayload Storage, TelemetryPayload Telemetry); |     private sealed record HealthPayload(string Status, DateTimeOffset StartedAt, double UptimeSeconds, StoragePayload Storage, TelemetryPayload Telemetry); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -0,0 +1,104 @@ | |||||||
|  | using System; | ||||||
|  | using System.Collections.Generic; | ||||||
|  | using System.Linq; | ||||||
|  | using System.Net; | ||||||
|  | using System.Security.Claims; | ||||||
|  | using System.Threading.Tasks; | ||||||
|  | using Microsoft.AspNetCore.Http; | ||||||
|  | using Microsoft.Extensions.Logging; | ||||||
|  | using Microsoft.Extensions.Options; | ||||||
|  | using StellaOps.Auth.Abstractions; | ||||||
|  | using StellaOps.Feedser.WebService.Options; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Feedser.WebService.Filters; | ||||||
|  |  | ||||||
|  | /// <summary> | ||||||
|  | /// Emits structured audit logs for job endpoint authorization decisions, including bypass usage. | ||||||
|  | /// </summary> | ||||||
|  | public sealed class JobAuthorizationAuditFilter : IEndpointFilter | ||||||
|  | { | ||||||
|  |     internal const string LoggerName = "Feedser.Authorization.Audit"; | ||||||
|  |  | ||||||
|  |     public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) | ||||||
|  |     { | ||||||
|  |         ArgumentNullException.ThrowIfNull(context); | ||||||
|  |         ArgumentNullException.ThrowIfNull(next); | ||||||
|  |  | ||||||
|  |         var httpContext = context.HttpContext; | ||||||
|  |         var options = httpContext.RequestServices.GetRequiredService<IOptions<FeedserOptions>>().Value; | ||||||
|  |         var authority = options.Authority; | ||||||
|  |  | ||||||
|  |         if (authority is null || !authority.Enabled) | ||||||
|  |         { | ||||||
|  |             return await next(context).ConfigureAwait(false); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var logger = httpContext.RequestServices | ||||||
|  |             .GetRequiredService<ILoggerFactory>() | ||||||
|  |             .CreateLogger(LoggerName); | ||||||
|  |  | ||||||
|  |         var remoteAddress = httpContext.Connection.RemoteIpAddress; | ||||||
|  |         var matcher = new NetworkMaskMatcher(authority.BypassNetworks); | ||||||
|  |         var user = httpContext.User; | ||||||
|  |         var isAuthenticated = user?.Identity?.IsAuthenticated ?? false; | ||||||
|  |         var bypassUsed = !isAuthenticated && matcher.IsAllowed(remoteAddress); | ||||||
|  |  | ||||||
|  |         var result = await next(context).ConfigureAwait(false); | ||||||
|  |  | ||||||
|  |         var scopes = ExtractScopes(user); | ||||||
|  |         var subject = user?.FindFirst(StellaOpsClaimTypes.Subject)?.Value; | ||||||
|  |         var clientId = user?.FindFirst(StellaOpsClaimTypes.ClientId)?.Value; | ||||||
|  |  | ||||||
|  |         logger.LogInformation( | ||||||
|  |             "Feedser authorization audit route={Route} status={StatusCode} subject={Subject} clientId={ClientId} scopes={Scopes} bypass={Bypass} remote={RemoteAddress}", | ||||||
|  |             httpContext.Request.Path.Value ?? string.Empty, | ||||||
|  |             httpContext.Response.StatusCode, | ||||||
|  |             string.IsNullOrWhiteSpace(subject) ? "(anonymous)" : subject, | ||||||
|  |             string.IsNullOrWhiteSpace(clientId) ? "(none)" : clientId, | ||||||
|  |             scopes.Length == 0 ? "(none)" : string.Join(',', scopes), | ||||||
|  |             bypassUsed, | ||||||
|  |             remoteAddress?.ToString() ?? IPAddress.None.ToString()); | ||||||
|  |  | ||||||
|  |         return result; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private static string[] ExtractScopes(ClaimsPrincipal? principal) | ||||||
|  |     { | ||||||
|  |         if (principal is null) | ||||||
|  |         { | ||||||
|  |             return Array.Empty<string>(); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var values = new HashSet<string>(StringComparer.Ordinal); | ||||||
|  |  | ||||||
|  |         foreach (var claim in principal.FindAll(StellaOpsClaimTypes.ScopeItem)) | ||||||
|  |         { | ||||||
|  |             if (string.IsNullOrWhiteSpace(claim.Value)) | ||||||
|  |             { | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             values.Add(claim.Value); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         foreach (var claim in principal.FindAll(StellaOpsClaimTypes.Scope)) | ||||||
|  |         { | ||||||
|  |             if (string.IsNullOrWhiteSpace(claim.Value)) | ||||||
|  |             { | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var parts = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); | ||||||
|  |             foreach (var part in parts) | ||||||
|  |             { | ||||||
|  |                 var normalized = StellaOpsScopes.Normalize(part); | ||||||
|  |                 if (!string.IsNullOrEmpty(normalized)) | ||||||
|  |                 { | ||||||
|  |                     values.Add(normalized); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return values.Count == 0 ? Array.Empty<string>() : values.ToArray(); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -59,6 +59,8 @@ public sealed class FeedserOptions | |||||||
|     { |     { | ||||||
|         public bool Enabled { get; set; } |         public bool Enabled { get; set; } | ||||||
|  |  | ||||||
|  |         public bool AllowAnonymousFallback { get; set; } = true; | ||||||
|  |  | ||||||
|         public string Issuer { get; set; } = string.Empty; |         public string Issuer { get; set; } = string.Empty; | ||||||
|  |  | ||||||
|         public string? MetadataAddress { get; set; } |         public string? MetadataAddress { get; set; } | ||||||
| @@ -74,5 +76,13 @@ public sealed class FeedserOptions | |||||||
|         public IList<string> RequiredScopes { get; set; } = new List<string>(); |         public IList<string> RequiredScopes { get; set; } = new List<string>(); | ||||||
|  |  | ||||||
|         public IList<string> BypassNetworks { get; set; } = new List<string>(); |         public IList<string> BypassNetworks { get; set; } = new List<string>(); | ||||||
|  |  | ||||||
|  |         public string? ClientId { get; set; } | ||||||
|  |  | ||||||
|  |         public string? ClientSecret { get; set; } | ||||||
|  |  | ||||||
|  |         public string? ClientSecretFile { get; set; } | ||||||
|  |  | ||||||
|  |         public IList<string> ClientScopes { get; set; } = new List<string>(); | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -0,0 +1,46 @@ | |||||||
|  | using System; | ||||||
|  | using System.IO; | ||||||
|  |  | ||||||
|  | namespace StellaOps.Feedser.WebService.Options; | ||||||
|  |  | ||||||
|  | /// <summary> | ||||||
|  | /// Post-configuration helpers for <see cref="FeedserOptions"/>. | ||||||
|  | /// </summary> | ||||||
|  | public static class FeedserOptionsPostConfigure | ||||||
|  | { | ||||||
|  |     /// <summary> | ||||||
|  |     /// Applies derived settings that require filesystem access, such as loading client secrets from disk. | ||||||
|  |     /// </summary> | ||||||
|  |     /// <param name="options">The options to mutate.</param> | ||||||
|  |     /// <param name="contentRootPath">Application content root used to resolve relative paths.</param> | ||||||
|  |     public static void Apply(FeedserOptions options, string contentRootPath) | ||||||
|  |     { | ||||||
|  |         ArgumentNullException.ThrowIfNull(options); | ||||||
|  |  | ||||||
|  |         options.Authority ??= new FeedserOptions.AuthorityOptions(); | ||||||
|  |  | ||||||
|  |         var authority = options.Authority; | ||||||
|  |         if (string.IsNullOrWhiteSpace(authority.ClientSecret) | ||||||
|  |             && !string.IsNullOrWhiteSpace(authority.ClientSecretFile)) | ||||||
|  |         { | ||||||
|  |             var resolvedPath = authority.ClientSecretFile!; | ||||||
|  |             if (!Path.IsPathRooted(resolvedPath)) | ||||||
|  |             { | ||||||
|  |                 resolvedPath = Path.Combine(contentRootPath, resolvedPath); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (!File.Exists(resolvedPath)) | ||||||
|  |             { | ||||||
|  |                 throw new InvalidOperationException($"Authority client secret file '{resolvedPath}' was not found."); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             var secret = File.ReadAllText(resolvedPath).Trim(); | ||||||
|  |             if (string.IsNullOrEmpty(secret)) | ||||||
|  |             { | ||||||
|  |                 throw new InvalidOperationException($"Authority client secret file '{resolvedPath}' is empty."); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             authority.ClientSecret = secret; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -32,12 +32,26 @@ public static class FeedserOptionsValidator | |||||||
|         NormalizeList(options.Authority.Audiences, toLower: false); |         NormalizeList(options.Authority.Audiences, toLower: false); | ||||||
|         NormalizeList(options.Authority.RequiredScopes, toLower: true); |         NormalizeList(options.Authority.RequiredScopes, toLower: true); | ||||||
|         NormalizeList(options.Authority.BypassNetworks, toLower: false); |         NormalizeList(options.Authority.BypassNetworks, toLower: false); | ||||||
|  |         NormalizeList(options.Authority.ClientScopes, toLower: true); | ||||||
|  |  | ||||||
|         if (options.Authority.RequiredScopes.Count == 0) |         if (options.Authority.RequiredScopes.Count == 0) | ||||||
|         { |         { | ||||||
|             options.Authority.RequiredScopes.Add(StellaOpsScopes.FeedserJobsTrigger); |             options.Authority.RequiredScopes.Add(StellaOpsScopes.FeedserJobsTrigger); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         if (options.Authority.ClientScopes.Count == 0) | ||||||
|  |         { | ||||||
|  |             foreach (var scope in options.Authority.RequiredScopes) | ||||||
|  |             { | ||||||
|  |                 options.Authority.ClientScopes.Add(scope); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (options.Authority.ClientScopes.Count == 0) | ||||||
|  |         { | ||||||
|  |             options.Authority.ClientScopes.Add(StellaOpsScopes.FeedserJobsTrigger); | ||||||
|  |         } | ||||||
|  |  | ||||||
|         if (options.Authority.BackchannelTimeoutSeconds <= 0) |         if (options.Authority.BackchannelTimeoutSeconds <= 0) | ||||||
|         { |         { | ||||||
|             throw new InvalidOperationException("Authority backchannelTimeoutSeconds must be greater than zero."); |             throw new InvalidOperationException("Authority backchannelTimeoutSeconds must be greater than zero."); | ||||||
| @@ -74,6 +88,19 @@ public static class FeedserOptionsValidator | |||||||
|             { |             { | ||||||
|                 throw new InvalidOperationException("Authority audiences must include at least one entry when authority is enabled."); |                 throw new InvalidOperationException("Authority audiences must include at least one entry when authority is enabled."); | ||||||
|             } |             } | ||||||
|  |  | ||||||
|  |             if (!options.Authority.AllowAnonymousFallback) | ||||||
|  |             { | ||||||
|  |                 if (string.IsNullOrWhiteSpace(options.Authority.ClientId)) | ||||||
|  |                 { | ||||||
|  |                     throw new InvalidOperationException("Authority clientId must be configured when anonymous fallback is disabled."); | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 if (string.IsNullOrWhiteSpace(options.Authority.ClientSecret)) | ||||||
|  |                 { | ||||||
|  |                     throw new InvalidOperationException("Authority clientSecret must be configured when anonymous fallback is disabled."); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if (!Enum.TryParse(options.Telemetry.MinimumLogLevel, ignoreCase: true, out LogLevel _)) |         if (!Enum.TryParse(options.Telemetry.MinimumLogLevel, ignoreCase: true, out LogLevel _)) | ||||||
|   | |||||||
| @@ -17,11 +17,12 @@ using StellaOps.Feedser.Core.Jobs; | |||||||
| using StellaOps.Feedser.Storage.Mongo; | using StellaOps.Feedser.Storage.Mongo; | ||||||
| using StellaOps.Feedser.WebService.Diagnostics; | using StellaOps.Feedser.WebService.Diagnostics; | ||||||
| using Serilog; | using Serilog; | ||||||
| using StellaOps.Feedser.Merge; | using StellaOps.Feedser.Merge; | ||||||
| using StellaOps.Feedser.Merge.Services; | using StellaOps.Feedser.Merge.Services; | ||||||
| using StellaOps.Feedser.WebService.Extensions; | using StellaOps.Feedser.WebService.Extensions; | ||||||
| using StellaOps.Feedser.WebService.Jobs; | using StellaOps.Feedser.WebService.Jobs; | ||||||
| using StellaOps.Feedser.WebService.Options; | using StellaOps.Feedser.WebService.Options; | ||||||
|  | using StellaOps.Feedser.WebService.Filters; | ||||||
| using Serilog.Events; | using Serilog.Events; | ||||||
| using StellaOps.Plugin.DependencyInjection; | using StellaOps.Plugin.DependencyInjection; | ||||||
| using StellaOps.Plugin.Hosting; | using StellaOps.Plugin.Hosting; | ||||||
| @@ -43,13 +44,23 @@ builder.Configuration.AddStellaOpsDefaults(options => | |||||||
|     }; |     }; | ||||||
| }); | }); | ||||||
|  |  | ||||||
| var feedserOptions = builder.Configuration.BindOptions<FeedserOptions>(postConfigure: (opts, _) => FeedserOptionsValidator.Validate(opts)); | var contentRootPath = builder.Environment.ContentRootPath; | ||||||
| builder.Services.AddOptions<FeedserOptions>() |  | ||||||
|     .Bind(builder.Configuration) | var feedserOptions = builder.Configuration.BindOptions<FeedserOptions>(postConfigure: (opts, _) => | ||||||
|     .PostConfigure(FeedserOptionsValidator.Validate) | { | ||||||
|     .ValidateOnStart(); |     FeedserOptionsPostConfigure.Apply(opts, contentRootPath); | ||||||
|  |     FeedserOptionsValidator.Validate(opts); | ||||||
| builder.ConfigureFeedserTelemetry(feedserOptions); | }); | ||||||
|  | builder.Services.AddOptions<FeedserOptions>() | ||||||
|  |     .Bind(builder.Configuration) | ||||||
|  |     .PostConfigure(options => | ||||||
|  |     { | ||||||
|  |         FeedserOptionsPostConfigure.Apply(options, contentRootPath); | ||||||
|  |         FeedserOptionsValidator.Validate(options); | ||||||
|  |     }) | ||||||
|  |     .ValidateOnStart(); | ||||||
|  |  | ||||||
|  | builder.ConfigureFeedserTelemetry(feedserOptions); | ||||||
|  |  | ||||||
| builder.Services.AddMongoStorage(storageOptions => | builder.Services.AddMongoStorage(storageOptions => | ||||||
| { | { | ||||||
| @@ -64,9 +75,9 @@ builder.Services.AddBuiltInFeedserJobs(); | |||||||
|  |  | ||||||
| builder.Services.AddSingleton<ServiceStatus>(sp => new ServiceStatus(sp.GetRequiredService<TimeProvider>())); | builder.Services.AddSingleton<ServiceStatus>(sp => new ServiceStatus(sp.GetRequiredService<TimeProvider>())); | ||||||
|  |  | ||||||
| var authorityEnabled = feedserOptions.Authority is { Enabled: true }; | var authorityConfigured = feedserOptions.Authority is { Enabled: true }; | ||||||
|  |  | ||||||
| if (authorityEnabled) | if (authorityConfigured) | ||||||
| { | { | ||||||
|     builder.Services.AddStellaOpsResourceServerAuthentication( |     builder.Services.AddStellaOpsResourceServerAuthentication( | ||||||
|         builder.Configuration, |         builder.Configuration, | ||||||
| @@ -109,9 +120,20 @@ var pluginHostOptions = BuildPluginOptions(feedserOptions, builder.Environment.C | |||||||
| builder.Services.RegisterPluginRoutines(builder.Configuration, pluginHostOptions); | builder.Services.RegisterPluginRoutines(builder.Configuration, pluginHostOptions); | ||||||
|  |  | ||||||
| builder.Services.AddEndpointsApiExplorer(); | builder.Services.AddEndpointsApiExplorer(); | ||||||
|  |  | ||||||
| var app = builder.Build(); | var app = builder.Build(); | ||||||
|  |  | ||||||
|  | var resolvedFeedserOptions = app.Services.GetRequiredService<IOptions<FeedserOptions>>().Value; | ||||||
|  | var resolvedAuthority = resolvedFeedserOptions.Authority ?? new FeedserOptions.AuthorityOptions(); | ||||||
|  | authorityConfigured = resolvedAuthority.Enabled; | ||||||
|  | var enforceAuthority = resolvedAuthority.Enabled && !resolvedAuthority.AllowAnonymousFallback; | ||||||
|  |  | ||||||
|  | if (resolvedAuthority.Enabled && resolvedAuthority.AllowAnonymousFallback) | ||||||
|  | { | ||||||
|  |     app.Logger.LogWarning( | ||||||
|  |         "Authority authentication is configured but anonymous fallback remains enabled. Set authority.allowAnonymousFallback to false before 2025-12-31 to complete the rollout."); | ||||||
|  | } | ||||||
|  |  | ||||||
| var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web); | var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web); | ||||||
| jsonOptions.Converters.Add(new JsonStringEnumConverter()); | jsonOptions.Converters.Add(new JsonStringEnumConverter()); | ||||||
|  |  | ||||||
| @@ -160,7 +182,46 @@ app.UseExceptionHandler(errorApp => | |||||||
|     }); |     }); | ||||||
| }); | }); | ||||||
|  |  | ||||||
| if (authorityEnabled) | if (authorityConfigured) | ||||||
|  | { | ||||||
|  |     app.Use(async (context, next) => | ||||||
|  |     { | ||||||
|  |         await next().ConfigureAwait(false); | ||||||
|  |  | ||||||
|  |         if (!context.Request.Path.StartsWithSegments("/jobs", StringComparison.OrdinalIgnoreCase)) | ||||||
|  |         { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (context.Response.StatusCode != StatusCodes.Status401Unauthorized) | ||||||
|  |         { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var optionsMonitor = context.RequestServices.GetRequiredService<IOptions<FeedserOptions>>().Value.Authority; | ||||||
|  |         if (optionsMonitor is null || !optionsMonitor.Enabled) | ||||||
|  |         { | ||||||
|  |             return; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var logger = context.RequestServices | ||||||
|  |             .GetRequiredService<ILoggerFactory>() | ||||||
|  |             .CreateLogger(JobAuthorizationAuditFilter.LoggerName); | ||||||
|  |  | ||||||
|  |         var matcher = new NetworkMaskMatcher(optionsMonitor.BypassNetworks); | ||||||
|  |         var remote = context.Connection.RemoteIpAddress; | ||||||
|  |         var bypassAllowed = matcher.IsAllowed(remote); | ||||||
|  |  | ||||||
|  |         logger.LogWarning( | ||||||
|  |             "Feedser authorization denied route={Route} remote={RemoteAddress} bypassAllowed={BypassAllowed} hasPrincipal={HasPrincipal}", | ||||||
|  |             context.Request.Path.Value ?? string.Empty, | ||||||
|  |             remote?.ToString() ?? "unknown", | ||||||
|  |             bypassAllowed, | ||||||
|  |             context.User?.Identity?.IsAuthenticated ?? false); | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | if (authorityConfigured) | ||||||
| { | { | ||||||
|     app.UseAuthentication(); |     app.UseAuthentication(); | ||||||
|     app.UseAuthorization(); |     app.UseAuthorization(); | ||||||
| @@ -360,12 +421,12 @@ var jobsListEndpoint = app.MapGet("/jobs", async (string? kind, int? limit, IJob | |||||||
|     var runs = await coordinator.GetRecentRunsAsync(kind, take, cancellationToken).ConfigureAwait(false); |     var runs = await coordinator.GetRecentRunsAsync(kind, take, cancellationToken).ConfigureAwait(false); | ||||||
|     var payload = runs.Select(JobRunResponse.FromSnapshot).ToArray(); |     var payload = runs.Select(JobRunResponse.FromSnapshot).ToArray(); | ||||||
|     return JsonResult(payload); |     return JsonResult(payload); | ||||||
| }); | }).AddEndpointFilter<JobAuthorizationAuditFilter>(); | ||||||
| if (authorityEnabled) | if (enforceAuthority) | ||||||
| { | { | ||||||
|     jobsListEndpoint.RequireAuthorization(JobsPolicyName); |     jobsListEndpoint.RequireAuthorization(JobsPolicyName); | ||||||
| } | } | ||||||
|  |  | ||||||
| var jobByIdEndpoint = app.MapGet("/jobs/{runId:guid}", async (Guid runId, IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) => | var jobByIdEndpoint = app.MapGet("/jobs/{runId:guid}", async (Guid runId, IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) => | ||||||
| { | { | ||||||
|     ApplyNoCache(context.Response); |     ApplyNoCache(context.Response); | ||||||
| @@ -377,12 +438,12 @@ var jobByIdEndpoint = app.MapGet("/jobs/{runId:guid}", async (Guid runId, IJobCo | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     return JsonResult(JobRunResponse.FromSnapshot(run)); |     return JsonResult(JobRunResponse.FromSnapshot(run)); | ||||||
| }); | }).AddEndpointFilter<JobAuthorizationAuditFilter>(); | ||||||
| if (authorityEnabled) | if (enforceAuthority) | ||||||
| { | { | ||||||
|     jobByIdEndpoint.RequireAuthorization(JobsPolicyName); |     jobByIdEndpoint.RequireAuthorization(JobsPolicyName); | ||||||
| } | } | ||||||
|  |  | ||||||
| var jobDefinitionsEndpoint = app.MapGet("/jobs/definitions", async (IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) => | var jobDefinitionsEndpoint = app.MapGet("/jobs/definitions", async (IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) => | ||||||
| { | { | ||||||
|     ApplyNoCache(context.Response); |     ApplyNoCache(context.Response); | ||||||
| @@ -402,14 +463,14 @@ var jobDefinitionsEndpoint = app.MapGet("/jobs/definitions", async (IJobCoordina | |||||||
|         lastRuns.TryGetValue(definition.Kind, out var lastRun); |         lastRuns.TryGetValue(definition.Kind, out var lastRun); | ||||||
|         responses.Add(JobDefinitionResponse.FromDefinition(definition, lastRun)); |         responses.Add(JobDefinitionResponse.FromDefinition(definition, lastRun)); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     return JsonResult(responses); |     return JsonResult(responses); | ||||||
| }); | }).AddEndpointFilter<JobAuthorizationAuditFilter>(); | ||||||
| if (authorityEnabled) | if (enforceAuthority) | ||||||
| { | { | ||||||
|     jobDefinitionsEndpoint.RequireAuthorization(JobsPolicyName); |     jobDefinitionsEndpoint.RequireAuthorization(JobsPolicyName); | ||||||
| } | } | ||||||
|  |  | ||||||
| var jobDefinitionEndpoint = app.MapGet("/jobs/definitions/{kind}", async (string kind, IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) => | var jobDefinitionEndpoint = app.MapGet("/jobs/definitions/{kind}", async (string kind, IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) => | ||||||
| { | { | ||||||
|     ApplyNoCache(context.Response); |     ApplyNoCache(context.Response); | ||||||
| @@ -424,15 +485,15 @@ var jobDefinitionEndpoint = app.MapGet("/jobs/definitions/{kind}", async (string | |||||||
|  |  | ||||||
|     var lastRuns = await coordinator.GetLastRunsAsync(new[] { definition.Kind }, cancellationToken).ConfigureAwait(false); |     var lastRuns = await coordinator.GetLastRunsAsync(new[] { definition.Kind }, cancellationToken).ConfigureAwait(false); | ||||||
|     lastRuns.TryGetValue(definition.Kind, out var lastRun); |     lastRuns.TryGetValue(definition.Kind, out var lastRun); | ||||||
|  |  | ||||||
|     var response = JobDefinitionResponse.FromDefinition(definition, lastRun); |     var response = JobDefinitionResponse.FromDefinition(definition, lastRun); | ||||||
|     return JsonResult(response); |     return JsonResult(response); | ||||||
| }); | }).AddEndpointFilter<JobAuthorizationAuditFilter>(); | ||||||
| if (authorityEnabled) | if (enforceAuthority) | ||||||
| { | { | ||||||
|     jobDefinitionEndpoint.RequireAuthorization(JobsPolicyName); |     jobDefinitionEndpoint.RequireAuthorization(JobsPolicyName); | ||||||
| } | } | ||||||
|  |  | ||||||
| var jobDefinitionRunsEndpoint = app.MapGet("/jobs/definitions/{kind}/runs", async (string kind, int? limit, IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) => | var jobDefinitionRunsEndpoint = app.MapGet("/jobs/definitions/{kind}/runs", async (string kind, int? limit, IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) => | ||||||
| { | { | ||||||
|     ApplyNoCache(context.Response); |     ApplyNoCache(context.Response); | ||||||
| @@ -445,29 +506,29 @@ var jobDefinitionRunsEndpoint = app.MapGet("/jobs/definitions/{kind}/runs", asyn | |||||||
|         return Problem(context, "Job definition not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"Job kind '{kind}' is not registered."); |         return Problem(context, "Job definition not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"Job kind '{kind}' is not registered."); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     var take = Math.Clamp(limit.GetValueOrDefault(20), 1, 200); |     var take = Math.Clamp(limit.GetValueOrDefault(20), 1, 200); | ||||||
|     var runs = await coordinator.GetRecentRunsAsync(kind, take, cancellationToken).ConfigureAwait(false); |     var runs = await coordinator.GetRecentRunsAsync(kind, take, cancellationToken).ConfigureAwait(false); | ||||||
|     var payload = runs.Select(JobRunResponse.FromSnapshot).ToArray(); |     var payload = runs.Select(JobRunResponse.FromSnapshot).ToArray(); | ||||||
|     return JsonResult(payload); |     return JsonResult(payload); | ||||||
| }); | }).AddEndpointFilter<JobAuthorizationAuditFilter>(); | ||||||
| if (authorityEnabled) | if (enforceAuthority) | ||||||
| { | { | ||||||
|     jobDefinitionRunsEndpoint.RequireAuthorization(JobsPolicyName); |     jobDefinitionRunsEndpoint.RequireAuthorization(JobsPolicyName); | ||||||
| } | } | ||||||
|  |  | ||||||
| var activeJobsEndpoint = app.MapGet("/jobs/active", async (IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) => | var activeJobsEndpoint = app.MapGet("/jobs/active", async (IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) => | ||||||
| { | { | ||||||
|     ApplyNoCache(context.Response); |     ApplyNoCache(context.Response); | ||||||
|  |  | ||||||
|     var runs = await coordinator.GetActiveRunsAsync(cancellationToken).ConfigureAwait(false); |     var runs = await coordinator.GetActiveRunsAsync(cancellationToken).ConfigureAwait(false); | ||||||
|     var payload = runs.Select(JobRunResponse.FromSnapshot).ToArray(); |     var payload = runs.Select(JobRunResponse.FromSnapshot).ToArray(); | ||||||
|     return JsonResult(payload); |     return JsonResult(payload); | ||||||
| }); | }).AddEndpointFilter<JobAuthorizationAuditFilter>(); | ||||||
| if (authorityEnabled) | if (enforceAuthority) | ||||||
| { | { | ||||||
|     activeJobsEndpoint.RequireAuthorization(JobsPolicyName); |     activeJobsEndpoint.RequireAuthorization(JobsPolicyName); | ||||||
| } | } | ||||||
|  |  | ||||||
| var triggerJobEndpoint = app.MapPost("/jobs/{*jobKind}", async (string jobKind, JobTriggerRequest request, IJobCoordinator coordinator, HttpContext context) => | var triggerJobEndpoint = app.MapPost("/jobs/{*jobKind}", async (string jobKind, JobTriggerRequest request, IJobCoordinator coordinator, HttpContext context) => | ||||||
| { | { | ||||||
|     ApplyNoCache(context.Response); |     ApplyNoCache(context.Response); | ||||||
| @@ -542,13 +603,13 @@ var triggerJobEndpoint = app.MapPost("/jobs/{*jobKind}", async (string jobKind, | |||||||
|  |  | ||||||
|             return Problem(context, "Job execution failed", StatusCodes.Status500InternalServerError, ProblemTypes.JobFailure, result.ErrorMessage, extensions); |             return Problem(context, "Job execution failed", StatusCodes.Status500InternalServerError, ProblemTypes.JobFailure, result.ErrorMessage, extensions); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         default: |         default: | ||||||
|             JobMetrics.TriggerFailureCounter.Add(1, tags); |             JobMetrics.TriggerFailureCounter.Add(1, tags); | ||||||
|             return Problem(context, "Unexpected job outcome", StatusCodes.Status500InternalServerError, ProblemTypes.JobFailure, $"Job '{jobKind}' returned outcome '{outcome}'."); |             return Problem(context, "Unexpected job outcome", StatusCodes.Status500InternalServerError, ProblemTypes.JobFailure, $"Job '{jobKind}' returned outcome '{outcome}'."); | ||||||
|     } |     } | ||||||
| }); | }).AddEndpointFilter<JobAuthorizationAuditFilter>(); | ||||||
| if (authorityEnabled) | if (enforceAuthority) | ||||||
| { | { | ||||||
|     triggerJobEndpoint.RequireAuthorization(JobsPolicyName); |     triggerJobEndpoint.RequireAuthorization(JobsPolicyName); | ||||||
| } | } | ||||||
|   | |||||||
| @@ -14,4 +14,10 @@ | |||||||
| |Endpoint smoke tests (health/ready/jobs error paths)|QA|WebService|DONE – WebServiceEndpointsTests assert success and problem responses for health, ready, and job trigger error paths.| | |Endpoint smoke tests (health/ready/jobs error paths)|QA|WebService|DONE – WebServiceEndpointsTests assert success and problem responses for health, ready, and job trigger error paths.| | ||||||
| |Batch job definition last-run lookup|BE-Base|Core|DONE – definitions endpoint now precomputes kinds array and reuses batched last-run dictionary; manual smoke verified via local GET `/jobs/definitions`.| | |Batch job definition last-run lookup|BE-Base|Core|DONE – definitions endpoint now precomputes kinds array and reuses batched last-run dictionary; manual smoke verified via local GET `/jobs/definitions`.| | ||||||
| |Add no-cache headers to health/readiness/jobs APIs|BE-Base|WebService|DONE – helper applies Cache-Control/Pragma/Expires on all health/ready/jobs endpoints; awaiting automated probe tests once connector fixtures stabilize.| | |Add no-cache headers to health/readiness/jobs APIs|BE-Base|WebService|DONE – helper applies Cache-Control/Pragma/Expires on all health/ready/jobs endpoints; awaiting automated probe tests once connector fixtures stabilize.| | ||||||
| |Document authority toggle & scope requirements|Docs/Feedser|Authority integration|**TODO** – Update Feedser operator docs/sample configs explaining `authority.*` settings, bypass CIDRs, and required scopes before enabling in prod.| | |Authority configuration parity (FSR1)|DevEx/Feedser|Authority options schema|**DONE (2025-10-10)** – Options post-config loads clientSecretFile fallback, validators normalize scopes/audiences, and sample config documents issuer/credential/bypass settings.| | ||||||
|  | |Document authority toggle & scope requirements|Docs/Feedser|Authority integration|**DOING (2025-10-10)** – Quickstart updated with staging flag, client credentials, env overrides; operator guide refresh pending Docs guild review.| | ||||||
|  | |Plumb Authority client resilience options|BE-Base|Auth libraries LIB5|**TODO** – Bind retry/offline settings from the `authority` config block, flow them into `AddStellaOpsAuthClient`, and cover via WebService integration test.| | ||||||
|  | |Author ops guidance for resilience tuning|Docs/Feedser|Plumb Authority client resilience options|**TODO** – Extend operator/quickstart docs with recommended retry profiles, offline-tolerance guidance, and monitoring cues.| | ||||||
|  | |Document authority bypass logging patterns|Docs/Feedser|FSR3 logging|**TODO** – Capture new audit log fields (bypass, remote IP, subject) in operator docs and add troubleshooting guidance for cron bypasses.| | ||||||
|  | |Update Feedser operator guide for enforcement cutoff|Docs/Feedser|FSR1 rollout|**TODO** – Add `allowAnonymousFallback` sunset timeline and checklist to operator guide / runbooks before 2025-12-31 enforcement.|  | ||||||
|  | |Authority resilience adoption|Feedser WebService, Docs|Plumb Authority client resilience options|**BLOCKED (2025-10-10)** – Roll out retry/offline knobs to deployment docs and confirm CLI parity once LIB5 lands; unblock after resilience options wired and tested.|  | ||||||
|   | |||||||
							
								
								
									
										24
									
								
								src/StellaOps.Web/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/StellaOps.Web/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | |||||||
|  | # StellaOps Web Frontend | ||||||
|  |  | ||||||
|  | ## Mission | ||||||
|  | Design and build the StellaOps web user experience that surfaces backend capabilities (Authority, Feedser, Exporters) through an offline-friendly Angular application. | ||||||
|  |  | ||||||
|  | ## Team Composition | ||||||
|  | - **UX Specialist** – defines user journeys, interaction patterns, accessibility guidelines, and visual design language. | ||||||
|  | - **Angular Engineers** – implement the SPA, integrate with backend APIs, and ensure deterministic builds suitable for air-gapped deployments. | ||||||
|  |  | ||||||
|  | ## Operating Principles | ||||||
|  | - Favor modular Angular architecture (feature modules, shared UI kit) with strong typing via latest TypeScript/Angular releases. | ||||||
|  | - Align UI flows with backend contracts; coordinate with Authority and Feedser teams for API changes. | ||||||
|  | - Keep assets and build outputs deterministic and cacheable for Offline Kit packaging. | ||||||
|  | - Track work using the local `TASKS.md` board; keep statuses (TODO/DOING/REVIEW/BLOCKED/DONE) up to date. | ||||||
|  |  | ||||||
|  | ## Key Paths | ||||||
|  | - `src/StellaOps.Web` — Angular workspace (to be scaffolded). | ||||||
|  | - `docs/` — UX specs and mockups (to be added). | ||||||
|  | - `ops/` — Web deployment manifests for air-gapped environments (future). | ||||||
|  |  | ||||||
|  | ## Coordination | ||||||
|  | - Sync with DevEx for project scaffolding and build pipelines. | ||||||
|  | - Partner with Docs Guild to translate UX decisions into operator guides. | ||||||
|  | - Collaborate with Security Guild to validate authentication flows and session handling. | ||||||
							
								
								
									
										5
									
								
								src/StellaOps.Web/TASKS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src/StellaOps.Web/TASKS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | |||||||
|  | # StellaOps Web Task Board (UTC 2025-10-10) | ||||||
|  |  | ||||||
|  | | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||||
|  | |----|--------|----------|------------|-------------|---------------| | ||||||
|  | | WEB1.TRIVY-SETTINGS | TODO | UX Specialist, Angular Eng | Backend `/exporters/trivy-db` contract | Implement Trivy DB exporter settings panel with `publishFull`, `publishDelta`, `includeFull`, `includeDelta` toggles and “Run export now” action using future `/exporters/trivy-db/settings` API. | ✅ Panel wired to mocked API; ✅ Overrides persisted via settings endpoint; ✅ Manual run button reuses overrides. | | ||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,6 +1,6 @@ | |||||||
| { | { | ||||||
|   "sdk": { |   "sdk": { | ||||||
|     "version": "10.0.100-preview.7.25380.108", |     "version": "10.0.100-preview.7.25380.108", | ||||||
|     "rollForward": "latestMinor" |     "rollForward": "latestMinor" | ||||||
|   } |   } | ||||||
| } | } | ||||||
		Reference in New Issue
	
	Block a user