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`   | ||||
| 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 | | ||||
| |---------|---------|-----------------------|-------| | ||||
| | `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 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 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. | ||||
| | `stellaops-cli config show` | Display resolved configuration | — | Masks secret values; helpful for air‑gapped installs | | ||||
| @@ -275,8 +286,20 @@ feedser: | ||||
| **Authentication** | ||||
|  | ||||
| - 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. | ||||
| - 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. | ||||
| - Anonymous operation is permitted only when Feedser runs with | ||||
|   `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** | ||||
|  | ||||
|   | ||||
| @@ -55,8 +55,9 @@ runtime wiring, CLI usage) and leaves connector/internal customization for later | ||||
|    - `GET /ready` – performs a MongoDB `ping` | ||||
|    - `GET /jobs` + `POST /jobs/{kind}` – inspect and trigger connector/export jobs | ||||
|  | ||||
|    > **Security note** – authentication is not wired yet; guard the service with | ||||
|    > network controls or a reverse proxy until auth middleware ships. | ||||
|   > **Security note** – authentication now ships via StellaOps Authority. Keep | ||||
|   > `authority.allowAnonymousFallback: true` only during the staged rollout and | ||||
|   > disable it before **2025-12-31 UTC** so tokens become mandatory. | ||||
|  | ||||
| ### 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 | | ||||
| | ------- | -------------------- | ------- | ------- | | ||||
| | `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 | | ||||
| | `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: | ||||
|  | ||||
| ```bash | ||||
| export STELLAOPS_BACKEND_URL="http://localhost:5000" | ||||
| 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 | ||||
|  | ||||
| # 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 | ||||
| rely on environment variables for ephemeral runners. | ||||
|  | ||||
| @@ -212,21 +234,56 @@ a problem document. | ||||
|  | ||||
| ## 5 · Next Steps | ||||
|  | ||||
| - Introduce authentication/authorization in the web service before exposing it on | ||||
|   shared networks. | ||||
| - Enable authority-backed authentication in non-production first. Set | ||||
|   `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). | ||||
| - Pair with the Feedser connector teams when enabling additional sources so their | ||||
|   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. | ||||
|   When auth is enabled, point `stellaops-cli` at the token issuer (client credentials | ||||
|   flow) or run it behind a proxy that injects bearer tokens. | ||||
| - Keep network-facing deployments behind reverse proxies or firewalls until the | ||||
|   authentication middleware ships and is fully validated. | ||||
|   Configure the CLI with matching Authority settings (`docs/09_API_CLI_REFERENCE.md`) | ||||
|   so that automation can obtain tokens with the same client credentials. Feedser | ||||
|   logs every job request with the client ID, subject (if present), scopes, and | ||||
|   a `bypass` flag so operators can audit cron traffic. | ||||
|   | ||||
| @@ -145,6 +145,21 @@ cosign verify ghcr.io/stellaops/backend@sha256:<DIGEST> \ | ||||
| | Audit events | Redis stream audit; export daily to SIEM                          | | ||||
| | 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 | ||||
|  | ||||
| | Layer                | Cadence                                                  | Method                         | | ||||
|   | ||||
| @@ -76,6 +76,44 @@ UI: [https://\<host\>:8443](https://<host>:8443) (self‑signed cert | ||||
| > `stella-ops:latest` with the immutable digest printed by | ||||
| > `docker images --digests`. | ||||
|  | ||||
| ### 1.1 · Feedser authority configuration | ||||
|  | ||||
| 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 | ||||
|   | ||||
							
								
								
									
										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. | ||||
|  | ||||
| **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 | ||||
| - Target **.NET 10 preview**, enable nullable, treat warnings as errors, and mark Authority plug-ins with `<IsAuthorityPlugin>true</IsAuthorityPlugin>`. | ||||
| - 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: | ||||
|   # 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" | ||||
|   | ||||
| @@ -38,6 +38,9 @@ telemetry: | ||||
|  | ||||
| authority: | ||||
|   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: "https://authority.stella-ops.local" | ||||
|   # Optional explicit metadata address; defaults to {issuer}/.well-known/openid-configuration. | ||||
| @@ -49,6 +52,13 @@ authority: | ||||
|     - "api://feedser" | ||||
|   requiredScopes: | ||||
|     - "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). | ||||
|   bypassNetworks: | ||||
|     - "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 | ||||
| COPY . . | ||||
| RUN dotnet restore StellaOps.sln | ||||
| RUN dotnet restore src/StellaOps.sln | ||||
| RUN dotnet publish src/StellaOps.Authority/StellaOps.Authority/StellaOps.Authority.csproj \ | ||||
|     -c Release \ | ||||
|     -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> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|   </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> | ||||
|     <FrameworkReference Include="Microsoft.AspNetCore.App" /> | ||||
|   </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> | ||||
|   | ||||
| @@ -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.Abstractions\StellaOps.Auth.Abstractions.csproj" /> | ||||
|   </ItemGroup> | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.5" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
|   | ||||
| @@ -23,6 +23,7 @@ public class StellaOpsAuthClientOptionsTests | ||||
|  | ||||
|         Assert.Equal(new[] { "authority.users.manage", "feedser.jobs.trigger" }, options.NormalizedScopes); | ||||
|         Assert.Equal(new Uri("https://authority.test"), options.AuthorityUri); | ||||
|         Assert.Equal<TimeSpan>(options.RetryDelays, options.NormalizedRetryDelays); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
| @@ -34,4 +35,50 @@ public class StellaOpsAuthClientOptionsTests | ||||
|  | ||||
|         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.Net; | ||||
| using System.Net.Http; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.DependencyInjection.Extensions; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using Microsoft.Extensions.Options; | ||||
| using Polly; | ||||
| using Polly.Extensions.Http; | ||||
|  | ||||
| namespace StellaOps.Auth.Client; | ||||
|  | ||||
| @@ -28,19 +33,19 @@ public static class ServiceCollectionExtensions | ||||
|         { | ||||
|             var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue; | ||||
|             client.Timeout = options.HttpTimeout; | ||||
|         }); | ||||
|         }).AddPolicyHandler(static (provider, _) => CreateRetryPolicy(provider)); | ||||
|  | ||||
|         services.AddHttpClient<StellaOpsJwksCache>((provider, client) => | ||||
|         { | ||||
|             var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue; | ||||
|             client.Timeout = options.HttpTimeout; | ||||
|         }); | ||||
|         }).AddPolicyHandler(static (provider, _) => CreateRetryPolicy(provider)); | ||||
|  | ||||
|         services.AddHttpClient<IStellaOpsTokenClient, StellaOpsTokenClient>((provider, client) => | ||||
|         { | ||||
|             var options = provider.GetRequiredService<IOptionsMonitor<StellaOpsAuthClientOptions>>().CurrentValue; | ||||
|             client.Timeout = options.HttpTimeout; | ||||
|         }); | ||||
|         }).AddPolicyHandler(static (provider, _) => CreateRetryPolicy(provider)); | ||||
|  | ||||
|         return services; | ||||
|     } | ||||
| @@ -62,4 +67,49 @@ public static class ServiceCollectionExtensions | ||||
|  | ||||
|         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> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|   </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> | ||||
|     <ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" /> | ||||
|     <ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" /> | ||||
|   </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" /> | ||||
|     <PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.0.1" /> | ||||
|   </ItemGroup> | ||||
|   <ItemGroup> | ||||
|     <None Include="README.NuGet.md" Pack="true" PackagePath="" /> | ||||
|   </ItemGroup> | ||||
|   <ItemGroup> | ||||
|     <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo"> | ||||
|       <_Parameter1>StellaOps.Auth.Client.Tests</_Parameter1> | ||||
|   | ||||
| @@ -10,7 +10,16 @@ namespace StellaOps.Auth.Client; | ||||
| /// </summary> | ||||
| 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<TimeSpan> retryDelays = new(DefaultRetryDelays); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Authority (issuer) base URL. | ||||
| @@ -32,6 +41,16 @@ public sealed class StellaOpsAuthClientOptions | ||||
|     /// </summary> | ||||
|     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> | ||||
|     /// Timeout applied to discovery and token HTTP requests. | ||||
|     /// </summary> | ||||
| @@ -52,6 +71,16 @@ public sealed class StellaOpsAuthClientOptions | ||||
|     /// </summary> | ||||
|     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> | ||||
|     /// Parsed Authority URI (populated after validation). | ||||
|     /// </summary> | ||||
| @@ -62,6 +91,11 @@ public sealed class StellaOpsAuthClientOptions | ||||
|     /// </summary> | ||||
|     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> | ||||
|     /// Validates required values and normalises scope entries. | ||||
|     /// </summary> | ||||
| @@ -97,8 +131,14 @@ public sealed class StellaOpsAuthClientOptions | ||||
|             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; | ||||
|         NormalizedScopes = NormalizeScopes(scopes); | ||||
|         NormalizedRetryDelays = EnableRetries ? NormalizeRetryDelays(retryDelays) : Array.Empty<TimeSpan>(); | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyList<string> NormalizeScopes(IList<string> values) | ||||
| @@ -140,4 +180,26 @@ public sealed class StellaOpsAuthClientOptions | ||||
|             ? Array.Empty<string>() | ||||
|             : 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 DateTimeOffset cacheExpiresAt; | ||||
|     private DateTimeOffset offlineExpiresAt; | ||||
|  | ||||
|     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); | ||||
|  | ||||
|         using var request = new HttpRequestMessage(HttpMethod.Get, discoveryUri); | ||||
|         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) | ||||
|         try | ||||
|         { | ||||
|             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( | ||||
|         [property: System.Text.Json.Serialization.JsonPropertyName("token_endpoint")] string? TokenEndpoint, | ||||
|         [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> | ||||
|   | ||||
| @@ -21,6 +21,7 @@ public sealed class StellaOpsJwksCache | ||||
|  | ||||
|     private JsonWebKeySet? cachedSet; | ||||
|     private DateTimeOffset cacheExpiresAt; | ||||
|     private DateTimeOffset offlineExpiresAt; | ||||
|  | ||||
|     public StellaOpsJwksCache( | ||||
|         HttpClient httpClient, | ||||
| @@ -44,17 +45,72 @@ public sealed class StellaOpsJwksCache | ||||
|             return cachedSet; | ||||
|         } | ||||
|  | ||||
|         var options = optionsMonitor.CurrentValue; | ||||
|         var configuration = await discoveryCache.GetAsync(cancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|         logger?.LogDebug("Fetching JWKS from {JwksUri}.", configuration.JwksEndpoint); | ||||
|  | ||||
|         using var response = await httpClient.GetAsync(configuration.JwksEndpoint, cancellationToken).ConfigureAwait(false); | ||||
|         response.EnsureSuccessStatusCode(); | ||||
|         try | ||||
|         { | ||||
|             using var response = await httpClient.GetAsync(configuration.JwksEndpoint, cancellationToken).ConfigureAwait(false); | ||||
|             response.EnsureSuccessStatusCode(); | ||||
|  | ||||
|         var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); | ||||
|         cachedSet = new JsonWebKeySet(json); | ||||
|         cacheExpiresAt = now + optionsMonitor.CurrentValue.JwksCacheLifetime; | ||||
|             var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); | ||||
|             cachedSet = new JsonWebKeySet(json); | ||||
|             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> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|   </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> | ||||
|     <ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" /> | ||||
|     <ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" /> | ||||
| @@ -14,6 +33,10 @@ | ||||
|   </ItemGroup> | ||||
|   <ItemGroup> | ||||
|     <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> | ||||
|     <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo"> | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| using System; | ||||
| using System.IO; | ||||
| using StellaOps.Authority.Plugin.Standard; | ||||
|  | ||||
| namespace StellaOps.Authority.Plugin.Standard.Tests; | ||||
| @@ -53,4 +54,46 @@ public class StandardPluginOptionsTests | ||||
|         var ex = Assert.Throws<InvalidOperationException>(() => options.Validate("standard")); | ||||
|         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.Collections.Generic; | ||||
| using System.IO; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Configuration; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.Hosting; | ||||
| using Microsoft.Extensions.Options; | ||||
| using Mongo2Go; | ||||
| using MongoDB.Driver; | ||||
| using StellaOps.Authority.Plugins.Abstractions; | ||||
| using StellaOps.Authority.Plugin.Standard; | ||||
| using StellaOps.Authority.Plugin.Standard.Bootstrap; | ||||
| using StellaOps.Authority.Plugin.Standard.Storage; | ||||
| using StellaOps.Authority.Storage.Mongo.Documents; | ||||
| @@ -146,6 +149,61 @@ public class StandardPluginRegistrarTests | ||||
|         using var provider = services.BuildServiceProvider(); | ||||
|         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 | ||||
|   | ||||
| @@ -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.IO; | ||||
|  | ||||
| namespace StellaOps.Authority.Plugin.Standard; | ||||
|  | ||||
| @@ -12,6 +13,11 @@ internal sealed class StandardPluginOptions | ||||
|  | ||||
|     public TokenSigningOptions TokenSigning { get; set; } = new(); | ||||
|  | ||||
|     public void Normalize(string configPath) | ||||
|     { | ||||
|         TokenSigning.Normalize(configPath); | ||||
|     } | ||||
|  | ||||
|     public void Validate(string pluginName) | ||||
|     { | ||||
|         BootstrapUser?.Validate(pluginName); | ||||
| @@ -90,4 +96,35 @@ internal sealed class LockoutOptions | ||||
| internal sealed class TokenSigningOptions | ||||
| { | ||||
|     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<IClaimsEnricher>(sp => sp.GetRequiredService<StandardClaimsEnricher>()); | ||||
|  | ||||
|         var configPath = context.Plugin.Manifest.ConfigPath; | ||||
|  | ||||
|         context.Services.AddOptions<StandardPluginOptions>(pluginName) | ||||
|             .Bind(context.Plugin.Configuration) | ||||
|             .PostConfigure(options => options.Validate(pluginName)); | ||||
|             .PostConfigure(options => | ||||
|             { | ||||
|                 options.Normalize(configPath); | ||||
|                 options.Validate(pluginName); | ||||
|             }) | ||||
|             .ValidateOnStart(); | ||||
|  | ||||
|         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 Microsoft.Extensions.Configuration; | ||||
| using Microsoft.Extensions.Logging.Abstractions; | ||||
| @@ -18,6 +19,8 @@ namespace StellaOps.Authority.Tests.OpenIddict; | ||||
|  | ||||
| public class ClientCredentialsHandlersTests | ||||
| { | ||||
|     private static readonly ActivitySource TestActivitySource = new("StellaOps.Authority.Tests"); | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task ValidateClientCredentials_Rejects_WhenScopeNotAllowed() | ||||
|     { | ||||
| @@ -27,7 +30,7 @@ public class ClientCredentialsHandlersTests | ||||
|             allowedScopes: "jobs:read"); | ||||
|  | ||||
|         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 context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); | ||||
| @@ -48,7 +51,7 @@ public class ClientCredentialsHandlersTests | ||||
|             allowedScopes: "jobs:read jobs:trigger"); | ||||
|  | ||||
|         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 context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); | ||||
| @@ -75,7 +78,7 @@ public class ClientCredentialsHandlersTests | ||||
|         var descriptor = CreateDescriptor(clientDocument); | ||||
|         var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: descriptor); | ||||
|         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"); | ||||
|         transaction.Options.AccessTokenLifetime = TimeSpan.FromMinutes(30); | ||||
| @@ -106,6 +109,8 @@ public class ClientCredentialsHandlersTests | ||||
|  | ||||
| public class TokenValidationHandlersTests | ||||
| { | ||||
|     private static readonly ActivitySource TestActivitySource = new("StellaOps.Authority.Tests.TokenValidation"); | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task ValidateAccessTokenHandler_Rejects_WhenTokenRevoked() | ||||
|     { | ||||
| @@ -121,7 +126,9 @@ public class TokenValidationHandlersTests | ||||
|             tokenStore, | ||||
|             new TestClientStore(CreateClient()), | ||||
|             CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(CreateClient())), | ||||
|             TimeProvider.System); | ||||
|             TimeProvider.System, | ||||
|             TestActivitySource, | ||||
|             NullLogger<ValidateAccessTokenHandler>.Instance); | ||||
|  | ||||
|         var transaction = new OpenIddictServerTransaction | ||||
|         { | ||||
| @@ -161,7 +168,9 @@ public class TokenValidationHandlersTests | ||||
|             new TestTokenStore(), | ||||
|             new TestClientStore(clientDocument), | ||||
|             registry, | ||||
|             TimeProvider.System); | ||||
|             TimeProvider.System, | ||||
|             TestActivitySource, | ||||
|             NullLogger<ValidateAccessTokenHandler>.Instance); | ||||
|  | ||||
|         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 | ||||
| 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 | ||||
| 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 | ||||
| 	GlobalSection(SolutionConfigurationPlatforms) = preSolution | ||||
| 		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|x86.ActiveCfg = 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 | ||||
| 	GlobalSection(SolutionProperties) = preSolution | ||||
| 		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.Security.Claims; | ||||
| using System.Security.Cryptography; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using OpenIddict.Abstractions; | ||||
| using OpenIddict.Extensions; | ||||
| using OpenIddict.Server; | ||||
| @@ -17,13 +19,19 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle | ||||
| { | ||||
|     private readonly IAuthorityClientStore clientStore; | ||||
|     private readonly IAuthorityIdentityProviderRegistry registry; | ||||
|     private readonly ActivitySource activitySource; | ||||
|     private readonly ILogger<ValidateClientCredentialsHandler> logger; | ||||
|  | ||||
|     public ValidateClientCredentialsHandler( | ||||
|         IAuthorityClientStore clientStore, | ||||
|         IAuthorityIdentityProviderRegistry registry) | ||||
|         IAuthorityIdentityProviderRegistry registry, | ||||
|         ActivitySource activitySource, | ||||
|         ILogger<ValidateClientCredentialsHandler> logger) | ||||
|     { | ||||
|         this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore)); | ||||
|         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) | ||||
| @@ -35,9 +43,15 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle | ||||
|             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)) | ||||
|         { | ||||
|             context.Reject(OpenIddictConstants.Errors.InvalidClient, "Client identifier is required."); | ||||
|             logger.LogWarning("Client credentials validation failed: missing client identifier."); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
| @@ -45,6 +59,7 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle | ||||
|         if (document is null || document.Disabled) | ||||
|         { | ||||
|             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; | ||||
|         } | ||||
|  | ||||
| @@ -54,12 +69,14 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle | ||||
|             if (!registry.TryGet(document.Plugin, out provider)) | ||||
|             { | ||||
|                 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; | ||||
|             } | ||||
|  | ||||
|             if (!provider.Capabilities.SupportsClientProvisioning || provider.ClientProvisioning is null) | ||||
|             { | ||||
|                 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; | ||||
|             } | ||||
|         } | ||||
| @@ -69,6 +86,7 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle | ||||
|             !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."); | ||||
|             logger.LogWarning("Client credentials validation failed for {ClientId}: grant type not allowed.", document.ClientId); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
| @@ -78,6 +96,7 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle | ||||
|             if (string.IsNullOrWhiteSpace(document.SecretHash)) | ||||
|             { | ||||
|                 context.Reject(OpenIddictConstants.Errors.InvalidClient, "Client secret is not configured."); | ||||
|                 logger.LogWarning("Client credentials validation failed for {ClientId}: secret not configured.", document.ClientId); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
| @@ -85,6 +104,7 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle | ||||
|                 !ClientCredentialHandlerHelpers.VerifySecret(context.ClientSecret, document.SecretHash)) | ||||
|             { | ||||
|                 context.Reject(OpenIddictConstants.Errors.InvalidClient, "Invalid client credentials."); | ||||
|                 logger.LogWarning("Client credentials validation failed for {ClientId}: secret verification failed.", document.ClientId); | ||||
|                 return; | ||||
|             } | ||||
|         } | ||||
| @@ -92,6 +112,7 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle | ||||
|                  !ClientCredentialHandlerHelpers.VerifySecret(context.ClientSecret, document.SecretHash)) | ||||
|         { | ||||
|             context.Reject(OpenIddictConstants.Errors.InvalidClient, "Invalid client credentials."); | ||||
|             logger.LogWarning("Client credentials validation failed for {ClientId}: secret verification failed.", document.ClientId); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
| @@ -103,6 +124,7 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle | ||||
|         if (resolvedScopes.InvalidScope is not null) | ||||
|         { | ||||
|             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; | ||||
|         } | ||||
|  | ||||
| @@ -110,9 +132,11 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle | ||||
|         if (provider is not null) | ||||
|         { | ||||
|             context.Transaction.Properties[AuthorityOpenIddictConstants.ClientProviderTransactionProperty] = provider.Name; | ||||
|             activity?.SetTag("authority.identity_provider", provider.Name); | ||||
|         } | ||||
|  | ||||
|         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 IAuthorityTokenStore tokenStore; | ||||
|     private readonly TimeProvider clock; | ||||
|     private readonly ActivitySource activitySource; | ||||
|     private readonly ILogger<HandleClientCredentialsHandler> logger; | ||||
|  | ||||
|     public HandleClientCredentialsHandler( | ||||
|         IAuthorityIdentityProviderRegistry registry, | ||||
|         IAuthorityTokenStore tokenStore, | ||||
|         TimeProvider clock) | ||||
|         TimeProvider clock, | ||||
|         ActivitySource activitySource, | ||||
|         ILogger<HandleClientCredentialsHandler> logger) | ||||
|     { | ||||
|         this.registry = registry ?? throw new ArgumentNullException(nameof(registry)); | ||||
|         this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore)); | ||||
|         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) | ||||
| @@ -141,6 +171,8 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler< | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         using var activity = activitySource.StartActivity("authority.token.handle_client_credentials", ActivityKind.Internal); | ||||
|  | ||||
|         if (!context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientTransactionProperty, out var value) || | ||||
|             value is not AuthorityClientDocument document) | ||||
|         { | ||||
| @@ -151,6 +183,9 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler< | ||||
|         var identity = new ClaimsIdentity(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); | ||||
|         identity.AddClaim(new Claim(OpenIddictConstants.Claims.Subject, 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); | ||||
|         if (string.IsNullOrEmpty(tokenId)) | ||||
| @@ -171,6 +206,7 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler< | ||||
|         var (provider, descriptor) = await ResolveProviderAsync(context, document).ConfigureAwait(false); | ||||
|         if (context.IsRejected) | ||||
|         { | ||||
|             logger.LogWarning("Client credentials request rejected for {ClientId} during provider resolution.", document.ClientId); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
| @@ -179,11 +215,13 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler< | ||||
|             if (!string.IsNullOrWhiteSpace(document.Plugin)) | ||||
|             { | ||||
|                 identity.SetClaim(StellaOpsClaimTypes.IdentityProvider, document.Plugin); | ||||
|                 activity?.SetTag("authority.identity_provider", document.Plugin); | ||||
|             } | ||||
|         } | ||||
|         else | ||||
|         { | ||||
|             identity.SetClaim(StellaOpsClaimTypes.IdentityProvider, provider.Name); | ||||
|             activity?.SetTag("authority.identity_provider", provider.Name); | ||||
|         } | ||||
|  | ||||
|         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 PersistTokenAsync(context, document, tokenId, grantedScopes).ConfigureAwait(false); | ||||
|         await PersistTokenAsync(context, document, tokenId, grantedScopes, activity).ConfigureAwait(false); | ||||
|  | ||||
|         context.Principal = principal; | ||||
|         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( | ||||
| @@ -255,7 +294,8 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler< | ||||
|         OpenIddictServerEvents.HandleTokenRequestContext context, | ||||
|         AuthorityClientDocument document, | ||||
|         string tokenId, | ||||
|         IReadOnlyCollection<string> scopes) | ||||
|         IReadOnlyCollection<string> scopes, | ||||
|         Activity? activity) | ||||
|     { | ||||
|         if (context.IsRejected) | ||||
|         { | ||||
| @@ -282,6 +322,7 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler< | ||||
|  | ||||
|         await tokenStore.InsertAsync(record, context.CancellationToken).ConfigureAwait(false); | ||||
|         context.Transaction.Properties[AuthorityOpenIddictConstants.TokenTransactionProperty] = record; | ||||
|         activity?.SetTag("authority.token_id", tokenId); | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -1,4 +1,6 @@ | ||||
| using System.Diagnostics; | ||||
| using System.Security.Claims; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using OpenIddict.Abstractions; | ||||
| using OpenIddict.Extensions; | ||||
| using OpenIddict.Server; | ||||
| @@ -11,10 +13,17 @@ namespace StellaOps.Authority.OpenIddict.Handlers; | ||||
| internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<OpenIddictServerEvents.ValidateTokenRequestContext> | ||||
| { | ||||
|     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.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource)); | ||||
|         this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context) | ||||
| @@ -26,20 +35,29 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op | ||||
|             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); | ||||
|         if (!selection.Succeeded) | ||||
|         { | ||||
|             context.Reject(selection.Error!, selection.Description); | ||||
|             logger.LogWarning("Password grant validation failed for {Username}: {Reason}.", context.Request.Username, selection.Description); | ||||
|             return default; | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(context.Request.Username) || string.IsNullOrEmpty(context.Request.Password)) | ||||
|         { | ||||
|             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; | ||||
|         } | ||||
|  | ||||
|         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; | ||||
|     } | ||||
| } | ||||
| @@ -47,10 +65,17 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op | ||||
| internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<OpenIddictServerEvents.HandleTokenRequestContext> | ||||
| { | ||||
|     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.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource)); | ||||
|         this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public async ValueTask HandleAsync(OpenIddictServerEvents.HandleTokenRequestContext context) | ||||
| @@ -62,6 +87,11 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open | ||||
|             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) | ||||
|             ? value as string | ||||
|             : null; | ||||
| @@ -72,6 +102,7 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open | ||||
|             if (!registry.TryGet(providerName!, out var explicitProvider)) | ||||
|             { | ||||
|                 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; | ||||
|             } | ||||
|  | ||||
| @@ -83,6 +114,7 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open | ||||
|             if (!selection.Succeeded) | ||||
|             { | ||||
|                 context.Reject(selection.Error!, selection.Description); | ||||
|                 logger.LogWarning("Password grant handling rejected {Username}: {Reason}.", context.Request.Username, selection.Description); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
| @@ -96,6 +128,7 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open | ||||
|         if (string.IsNullOrWhiteSpace(username) || string.IsNullOrEmpty(password)) | ||||
|         { | ||||
|             context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Both username and password must be provided."); | ||||
|             logger.LogWarning("Password grant handling rejected: missing credentials for {Username}.", username); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
| @@ -109,6 +142,7 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open | ||||
|             context.Reject( | ||||
|                 OpenIddictConstants.Errors.InvalidGrant, | ||||
|                 verification.Message ?? "Invalid username or password."); | ||||
|             logger.LogWarning("Password verification failed for {Username}: {Message}.", username, verification.Message); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
| @@ -147,5 +181,7 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open | ||||
|  | ||||
|         context.Principal = principal; | ||||
|         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 Microsoft.Extensions.Logging; | ||||
| using OpenIddict.Abstractions; | ||||
| using OpenIddict.Extensions; | ||||
| using OpenIddict.Server; | ||||
| @@ -16,17 +18,23 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open | ||||
|     private readonly IAuthorityClientStore clientStore; | ||||
|     private readonly IAuthorityIdentityProviderRegistry registry; | ||||
|     private readonly TimeProvider clock; | ||||
|     private readonly ActivitySource activitySource; | ||||
|     private readonly ILogger<ValidateAccessTokenHandler> logger; | ||||
|  | ||||
|     public ValidateAccessTokenHandler( | ||||
|         IAuthorityTokenStore tokenStore, | ||||
|         IAuthorityClientStore clientStore, | ||||
|         IAuthorityIdentityProviderRegistry registry, | ||||
|         TimeProvider clock) | ||||
|         TimeProvider clock, | ||||
|         ActivitySource activitySource, | ||||
|         ILogger<ValidateAccessTokenHandler> logger) | ||||
|     { | ||||
|         this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore)); | ||||
|         this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore)); | ||||
|         this.registry = registry ?? throw new ArgumentNullException(nameof(registry)); | ||||
|         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) | ||||
| @@ -43,6 +51,14 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open | ||||
|             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) | ||||
|             ? context.TokenId | ||||
|             : context.Principal.GetClaim(OpenIddictConstants.Claims.JwtId); | ||||
| @@ -55,16 +71,19 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open | ||||
|                 if (!string.Equals(tokenDocument.Status, "valid", StringComparison.OrdinalIgnoreCase)) | ||||
|                 { | ||||
|                     context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token is no longer active."); | ||||
|                     logger.LogWarning("Access token {TokenId} rejected: status {Status}.", tokenId, tokenDocument.Status); | ||||
|                     return; | ||||
|                 } | ||||
|  | ||||
|                 if (tokenDocument.ExpiresAt is { } expiresAt && expiresAt <= clock.GetUtcNow()) | ||||
|                 { | ||||
|                     context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token has expired."); | ||||
|                     logger.LogWarning("Access token {TokenId} rejected: expired at {ExpiresAt:o}.", tokenId, expiresAt); | ||||
|                     return; | ||||
|                 } | ||||
|  | ||||
|                 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) | ||||
|             { | ||||
|                 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; | ||||
|             } | ||||
|         } | ||||
| @@ -93,6 +113,7 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open | ||||
|         if (!registry.TryGet(providerName, out var provider)) | ||||
|         { | ||||
|             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; | ||||
|         } | ||||
|  | ||||
| @@ -106,8 +127,10 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open | ||||
|             if (user is null) | ||||
|             { | ||||
|                 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; | ||||
|             } | ||||
|             activity?.SetTag("authority.subject_id", subject); | ||||
|         } | ||||
|  | ||||
|         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); | ||||
|         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.Hosting; | ||||
| using Microsoft.Extensions.Options; | ||||
| using Microsoft.AspNetCore.RateLimiting; | ||||
| using Microsoft.Extensions.Logging.Abstractions; | ||||
| using OpenIddict.Abstractions; | ||||
| using OpenIddict.Server; | ||||
| @@ -68,6 +69,11 @@ var issuer = authorityOptions.Issuer ?? throw new InvalidOperationException("Aut | ||||
| builder.Services.AddSingleton(authorityOptions); | ||||
| builder.Services.AddSingleton<IOptions<StellaOpsAuthorityOptions>>(Options.Create(authorityOptions)); | ||||
|  | ||||
| builder.Services.AddRateLimiter(rateLimiterOptions => | ||||
| { | ||||
|     AuthorityRateLimiter.Configure(rateLimiterOptions, authorityOptions); | ||||
| }); | ||||
|  | ||||
| AuthorityPluginContext[] pluginContexts = AuthorityPluginConfigurationLoader | ||||
|     .Load(authorityOptions, builder.Environment.ContentRootPath) | ||||
|     .ToArray(); | ||||
| @@ -392,6 +398,7 @@ app.UseExceptionHandler(static errorApp => | ||||
| }); | ||||
|  | ||||
| app.UseRouting(); | ||||
| app.UseRateLimiter(); | ||||
| app.UseAuthentication(); | ||||
| 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.Collections.Generic; | ||||
| using System.IO; | ||||
| using System.Text; | ||||
| using System.Text.Json; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| @@ -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] | ||||
|     public async Task HandleAuthLogoutAsync_ClearsToken() | ||||
|     { | ||||
| @@ -432,4 +508,26 @@ public sealed class CommandHandlersTests | ||||
|             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_CLIENT_ID", "cli-env"); | ||||
|         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 | ||||
|         { | ||||
| @@ -35,6 +39,12 @@ public sealed class CliBootstrapperTests : IDisposable | ||||
|             Assert.Equal("https://authority.env", options.Authority.Url); | ||||
|             Assert.Equal("cli-env", options.Authority.ClientId); | ||||
|             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 | ||||
|         { | ||||
| @@ -43,6 +53,10 @@ public sealed class CliBootstrapperTests : IDisposable | ||||
|             Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_URL", null); | ||||
|             Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_CLIENT_ID", 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,9 +255,17 @@ internal static class CommandFactory | ||||
|             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(logout); | ||||
|         auth.Add(status); | ||||
|         auth.Add(whoami); | ||||
|         return auth; | ||||
|     } | ||||
|  | ||||
| @@ -268,13 +276,21 @@ internal static class CommandFactory | ||||
|  | ||||
|         show.SetAction((_, _) => | ||||
|         { | ||||
|             var authority = options.Authority ?? new StellaOpsCliAuthorityOptions(); | ||||
|             var lines = new[] | ||||
|             { | ||||
|                 $"Backend URL: {MaskIfEmpty(options.BackendUrl)}", | ||||
|                 $"API Key: {DescribeSecret(options.ApiKey)}", | ||||
|                 $"Scanner Cache: {options.ScannerCacheDirectory}", | ||||
|                 $"Results Directory: {options.ResultsDirectory}", | ||||
|                 $"Default Runner: {options.DefaultRunner}" | ||||
|                 $"Default Runner: {options.DefaultRunner}", | ||||
|                 $"Authority URL: {MaskIfEmpty(authority.Url)}", | ||||
|                 $"Authority Client ID: {MaskIfEmpty(authority.ClientId)}", | ||||
|                 $"Authority Client Secret: {DescribeSecret(authority.ClientSecret ?? string.Empty)}", | ||||
|                 $"Authority Username: {MaskIfEmpty(authority.Username)}", | ||||
|                 $"Authority Password: {DescribeSecret(authority.Password ?? string.Empty)}", | ||||
|                 $"Authority Scope: {MaskIfEmpty(authority.Scope)}", | ||||
|                 $"Authority Token Cache: {MaskIfEmpty(authority.TokenCacheDirectory ?? string.Empty)}" | ||||
|             }; | ||||
|  | ||||
|             foreach (var line in lines) | ||||
|   | ||||
| @@ -1,7 +1,9 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Diagnostics; | ||||
| using System.Globalization; | ||||
| using System.IO; | ||||
| using System.Text.Json; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| @@ -493,6 +495,317 @@ internal static class CommandHandlers | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public static async Task HandleAuthWhoAmIAsync( | ||||
|         IServiceProvider services, | ||||
|         StellaOpsCliOptions options, | ||||
|         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, | ||||
|   | ||||
| @@ -1,3 +1,5 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Globalization; | ||||
| using System.IO; | ||||
| using Microsoft.Extensions.Configuration; | ||||
| @@ -113,6 +115,82 @@ public static class CliBootstrapper | ||||
|                 authority.Password = string.IsNullOrWhiteSpace(authority.Password) ? null : authority.Password.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(); | ||||
|                 if (string.IsNullOrWhiteSpace(authority.TokenCacheDirectory)) | ||||
|                 { | ||||
| @@ -147,6 +225,46 @@ public static class CliBootstrapper | ||||
|         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() | ||||
|     { | ||||
|         var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); | ||||
|   | ||||
| @@ -1,3 +1,5 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using StellaOps.Auth.Abstractions; | ||||
|  | ||||
| namespace StellaOps.Cli.Configuration; | ||||
| @@ -38,4 +40,17 @@ public sealed class StellaOpsCliAuthorityOptions | ||||
|     public string Scope { get; set; } = StellaOpsScopes.FeedserJobsTrigger; | ||||
|  | ||||
|     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) | ||||
|                     ? StellaOps.Auth.Abstractions.StellaOpsScopes.FeedserJobsTrigger | ||||
|                     : 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; | ||||
|   | ||||
| @@ -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.| | ||||
| |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.| | ||||
| |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:CommandTimeout"] = "00:01:30", | ||||
|                     ["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("overrideDb", options.Storage.DatabaseName); | ||||
|         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.Json" Version="8.0.0" /> | ||||
|     <PackageReference Include="NetEscapades.Configuration.Yaml" Version="2.1.0" /> | ||||
|     <PackageReference Include="System.Threading.RateLimiting" Version="8.0.0" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|   | ||||
| @@ -2,6 +2,7 @@ using System; | ||||
| using System.Collections.Generic; | ||||
| using System.IO; | ||||
| using System.Linq; | ||||
| using System.Threading.RateLimiting; | ||||
| using StellaOps.Authority.Plugins.Abstractions; | ||||
|  | ||||
| namespace StellaOps.Configuration; | ||||
| @@ -74,6 +75,11 @@ public sealed class StellaOpsAuthorityOptions | ||||
|     /// </summary> | ||||
|     public AuthorityPluginSettings Plugins { get; } = new(); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Rate limiting configuration applied to Authority endpoints. | ||||
|     /// </summary> | ||||
|     public AuthorityRateLimitingOptions RateLimiting { get; } = new(); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Validates configured values and normalises collections. | ||||
|     /// </summary> | ||||
| @@ -109,6 +115,7 @@ public sealed class StellaOpsAuthorityOptions | ||||
|         NormaliseList(pluginDirectories); | ||||
|         NormaliseList(bypassNetworks); | ||||
|  | ||||
|         RateLimiting.Validate(); | ||||
|         Plugins.NormalizeAndValidate(); | ||||
|         Storage.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 | ||||
| { | ||||
|     /// <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. | ||||
| @@ -11,3 +11,4 @@ | ||||
| |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.| | ||||
| |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.|  | ||||
|   | ||||
| @@ -13,3 +13,4 @@ | ||||
| |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.| | ||||
| |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); | ||||
|     } | ||||
| } | ||||
| @@ -3,10 +3,14 @@ using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Net; | ||||
| using System.Net.Http.Json; | ||||
| using Microsoft.AspNetCore.Builder; | ||||
| using Microsoft.AspNetCore.Hosting; | ||||
| using Microsoft.AspNetCore.Mvc.Testing; | ||||
| using Microsoft.AspNetCore.TestHost; | ||||
| using Microsoft.Extensions.Configuration; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using Microsoft.Extensions.Options; | ||||
| using Mongo2Go; | ||||
| using StellaOps.Feedser.Core.Jobs; | ||||
| using StellaOps.Feedser.WebService.Jobs; | ||||
| @@ -214,27 +218,103 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime | ||||
|     [Fact] | ||||
|     public async Task JobsEndpointsAllowBypassWhenAuthorityEnabled() | ||||
|     { | ||||
|         using var factory = new FeedserApplicationFactory(_runner.ConnectionString, authority => | ||||
|         var environment = new Dictionary<string, string?> | ||||
|         { | ||||
|             authority.Enabled = true; | ||||
|             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"); | ||||
|         }); | ||||
|             ["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__BYPASSNETWORKS__0"] = "127.0.0.1/32", | ||||
|             ["FEEDSER_AUTHORITY__BYPASSNETWORKS__1"] = "::1/128", | ||||
|             ["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.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>(); | ||||
|         handler.Definitions = new[] { new JobDefinition("demo", typeof(DemoJob), TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(1), null, true) }; | ||||
|  | ||||
|         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.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> | ||||
| @@ -248,8 +328,13 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime | ||||
|         private readonly string? _previousTelemetryTracing; | ||||
|         private readonly string? _previousTelemetryMetrics; | ||||
|         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; | ||||
|             _authorityConfigure = authorityConfigure; | ||||
| @@ -267,6 +352,15 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime | ||||
|             Environment.SetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLELOGGING", "false"); | ||||
|             Environment.SetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLETRACING", "false"); | ||||
|             Environment.SetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLEMETRICS", "false"); | ||||
|             if (environmentOverrides is not null) | ||||
|             { | ||||
|                 foreach (var kvp in environmentOverrides) | ||||
|                 { | ||||
|                     var previous = Environment.GetEnvironmentVariable(kvp.Key); | ||||
|                     _additionalPreviousEnvironment[kvp.Key] = previous; | ||||
|                     Environment.SetEnvironmentVariable(kvp.Key, kvp.Value); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         protected override void ConfigureWebHost(IWebHostBuilder builder) | ||||
| @@ -281,6 +375,11 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime | ||||
|                 configurationBuilder.AddInMemoryCollection(settings!); | ||||
|             }); | ||||
|  | ||||
|             builder.ConfigureLogging(logging => | ||||
|             { | ||||
|                 logging.AddProvider(LoggerProvider); | ||||
|             }); | ||||
|  | ||||
|             builder.ConfigureServices(services => | ||||
|             { | ||||
|                 services.AddSingleton<StubJobCoordinator>(); | ||||
| @@ -299,6 +398,11 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime | ||||
|                     _authorityConfigure?.Invoke(options.Authority); | ||||
|                 }); | ||||
|             }); | ||||
|  | ||||
|             builder.ConfigureTestServices(services => | ||||
|             { | ||||
|                 services.AddSingleton<IStartupFilter, RemoteIpStartupFilter>(); | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         protected override void Dispose(bool disposing) | ||||
| @@ -311,6 +415,157 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime | ||||
|             Environment.SetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLELOGGING", _previousTelemetryLogging); | ||||
|             Environment.SetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLETRACING", _previousTelemetryTracing); | ||||
|             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() | ||||
|                 { | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -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 AllowAnonymousFallback { get; set; } = true; | ||||
|  | ||||
|         public string Issuer { get; set; } = string.Empty; | ||||
|  | ||||
|         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> 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.RequiredScopes, toLower: true); | ||||
|         NormalizeList(options.Authority.BypassNetworks, toLower: false); | ||||
|         NormalizeList(options.Authority.ClientScopes, toLower: true); | ||||
|  | ||||
|         if (options.Authority.RequiredScopes.Count == 0) | ||||
|         { | ||||
|             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) | ||||
|         { | ||||
|             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."); | ||||
|             } | ||||
|  | ||||
|             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 _)) | ||||
|   | ||||
| @@ -22,6 +22,7 @@ using StellaOps.Feedser.Merge.Services; | ||||
| using StellaOps.Feedser.WebService.Extensions; | ||||
| using StellaOps.Feedser.WebService.Jobs; | ||||
| using StellaOps.Feedser.WebService.Options; | ||||
| using StellaOps.Feedser.WebService.Filters; | ||||
| using Serilog.Events; | ||||
| using StellaOps.Plugin.DependencyInjection; | ||||
| using StellaOps.Plugin.Hosting; | ||||
| @@ -43,10 +44,20 @@ builder.Configuration.AddStellaOpsDefaults(options => | ||||
|     }; | ||||
| }); | ||||
|  | ||||
| var feedserOptions = builder.Configuration.BindOptions<FeedserOptions>(postConfigure: (opts, _) => FeedserOptionsValidator.Validate(opts)); | ||||
| var contentRootPath = builder.Environment.ContentRootPath; | ||||
|  | ||||
| var feedserOptions = builder.Configuration.BindOptions<FeedserOptions>(postConfigure: (opts, _) => | ||||
| { | ||||
|     FeedserOptionsPostConfigure.Apply(opts, contentRootPath); | ||||
|     FeedserOptionsValidator.Validate(opts); | ||||
| }); | ||||
| builder.Services.AddOptions<FeedserOptions>() | ||||
|     .Bind(builder.Configuration) | ||||
|     .PostConfigure(FeedserOptionsValidator.Validate) | ||||
|     .PostConfigure(options => | ||||
|     { | ||||
|         FeedserOptionsPostConfigure.Apply(options, contentRootPath); | ||||
|         FeedserOptionsValidator.Validate(options); | ||||
|     }) | ||||
|     .ValidateOnStart(); | ||||
|  | ||||
| builder.ConfigureFeedserTelemetry(feedserOptions); | ||||
| @@ -64,9 +75,9 @@ builder.Services.AddBuiltInFeedserJobs(); | ||||
|  | ||||
| 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.Configuration, | ||||
| @@ -112,6 +123,17 @@ builder.Services.AddEndpointsApiExplorer(); | ||||
|  | ||||
| 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); | ||||
| 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.UseAuthorization(); | ||||
| @@ -360,8 +421,8 @@ var jobsListEndpoint = app.MapGet("/jobs", async (string? kind, int? limit, IJob | ||||
|     var runs = await coordinator.GetRecentRunsAsync(kind, take, cancellationToken).ConfigureAwait(false); | ||||
|     var payload = runs.Select(JobRunResponse.FromSnapshot).ToArray(); | ||||
|     return JsonResult(payload); | ||||
| }); | ||||
| if (authorityEnabled) | ||||
| }).AddEndpointFilter<JobAuthorizationAuditFilter>(); | ||||
| if (enforceAuthority) | ||||
| { | ||||
|     jobsListEndpoint.RequireAuthorization(JobsPolicyName); | ||||
| } | ||||
| @@ -377,8 +438,8 @@ var jobByIdEndpoint = app.MapGet("/jobs/{runId:guid}", async (Guid runId, IJobCo | ||||
|     } | ||||
|  | ||||
|     return JsonResult(JobRunResponse.FromSnapshot(run)); | ||||
| }); | ||||
| if (authorityEnabled) | ||||
| }).AddEndpointFilter<JobAuthorizationAuditFilter>(); | ||||
| if (enforceAuthority) | ||||
| { | ||||
|     jobByIdEndpoint.RequireAuthorization(JobsPolicyName); | ||||
| } | ||||
| @@ -404,8 +465,8 @@ var jobDefinitionsEndpoint = app.MapGet("/jobs/definitions", async (IJobCoordina | ||||
|     } | ||||
|  | ||||
|     return JsonResult(responses); | ||||
| }); | ||||
| if (authorityEnabled) | ||||
| }).AddEndpointFilter<JobAuthorizationAuditFilter>(); | ||||
| if (enforceAuthority) | ||||
| { | ||||
|     jobDefinitionsEndpoint.RequireAuthorization(JobsPolicyName); | ||||
| } | ||||
| @@ -427,8 +488,8 @@ var jobDefinitionEndpoint = app.MapGet("/jobs/definitions/{kind}", async (string | ||||
|  | ||||
|     var response = JobDefinitionResponse.FromDefinition(definition, lastRun); | ||||
|     return JsonResult(response); | ||||
| }); | ||||
| if (authorityEnabled) | ||||
| }).AddEndpointFilter<JobAuthorizationAuditFilter>(); | ||||
| if (enforceAuthority) | ||||
| { | ||||
|     jobDefinitionEndpoint.RequireAuthorization(JobsPolicyName); | ||||
| } | ||||
| @@ -449,8 +510,8 @@ var jobDefinitionRunsEndpoint = app.MapGet("/jobs/definitions/{kind}/runs", asyn | ||||
|     var runs = await coordinator.GetRecentRunsAsync(kind, take, cancellationToken).ConfigureAwait(false); | ||||
|     var payload = runs.Select(JobRunResponse.FromSnapshot).ToArray(); | ||||
|     return JsonResult(payload); | ||||
| }); | ||||
| if (authorityEnabled) | ||||
| }).AddEndpointFilter<JobAuthorizationAuditFilter>(); | ||||
| if (enforceAuthority) | ||||
| { | ||||
|     jobDefinitionRunsEndpoint.RequireAuthorization(JobsPolicyName); | ||||
| } | ||||
| @@ -462,8 +523,8 @@ var activeJobsEndpoint = app.MapGet("/jobs/active", async (IJobCoordinator coord | ||||
|     var runs = await coordinator.GetActiveRunsAsync(cancellationToken).ConfigureAwait(false); | ||||
|     var payload = runs.Select(JobRunResponse.FromSnapshot).ToArray(); | ||||
|     return JsonResult(payload); | ||||
| }); | ||||
| if (authorityEnabled) | ||||
| }).AddEndpointFilter<JobAuthorizationAuditFilter>(); | ||||
| if (enforceAuthority) | ||||
| { | ||||
|     activeJobsEndpoint.RequireAuthorization(JobsPolicyName); | ||||
| } | ||||
| @@ -547,8 +608,8 @@ var triggerJobEndpoint = app.MapPost("/jobs/{*jobKind}", async (string jobKind, | ||||
|             JobMetrics.TriggerFailureCounter.Add(1, tags); | ||||
|             return Problem(context, "Unexpected job outcome", StatusCodes.Status500InternalServerError, ProblemTypes.JobFailure, $"Job '{jobKind}' returned outcome '{outcome}'."); | ||||
|     } | ||||
| }); | ||||
| if (authorityEnabled) | ||||
| }).AddEndpointFilter<JobAuthorizationAuditFilter>(); | ||||
| if (enforceAuthority) | ||||
| { | ||||
|     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.| | ||||
| |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.| | ||||
| |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. | | ||||
| @@ -7,171 +7,175 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72 | ||||
| EndProject | ||||
| Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Authority", "StellaOps.Authority", "{361838C4-72E2-1C48-5D76-CA6D1A861242}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Abstractions", "src\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj", "{D9F91EA0-8AF5-452A-86D8-52BACB2E39CB}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Abstractions", "StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj", "{D9F91EA0-8AF5-452A-86D8-52BACB2E39CB}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Client", "src\StellaOps.Authority\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj", "{5DBE2E9E-9905-47CE-B8DC-B25409AF1EF2}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Client", "StellaOps.Authority\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj", "{5DBE2E9E-9905-47CE-B8DC-B25409AF1EF2}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Configuration", "src\StellaOps.Configuration\StellaOps.Configuration.csproj", "{8BCEAAFC-9168-4CC0-AFDB-177E5F7C15C6}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Configuration", "StellaOps.Configuration\StellaOps.Configuration.csproj", "{8BCEAAFC-9168-4CC0-AFDB-177E5F7C15C6}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.ServerIntegration", "src\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj", "{46D35B4F-6A04-47FF-958B-5E6A73FCC059}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.ServerIntegration", "StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj", "{46D35B4F-6A04-47FF-958B-5E6A73FCC059}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "src\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{44A1241B-8ECF-4AFA-9972-452C39AD43D6}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{44A1241B-8ECF-4AFA-9972-452C39AD43D6}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority", "src\StellaOps.Authority\StellaOps.Authority\StellaOps.Authority.csproj", "{85AB3BB7-C493-4387-B39A-EB299AC37312}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority", "StellaOps.Authority\StellaOps.Authority\StellaOps.Authority.csproj", "{85AB3BB7-C493-4387-B39A-EB299AC37312}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugins.Abstractions", "src\StellaOps.Authority\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj", "{5C5E91CA-3F98-4E9A-922B-F6415EABD1A3}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugins.Abstractions", "StellaOps.Authority\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj", "{5C5E91CA-3F98-4E9A-922B-F6415EABD1A3}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugin.Standard", "src\StellaOps.Authority\StellaOps.Authority.Plugin.Standard\StellaOps.Authority.Plugin.Standard.csproj", "{93DB06DC-B254-48A9-8F2C-6130A5658F27}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugin.Standard", "StellaOps.Authority\StellaOps.Authority.Plugin.Standard\StellaOps.Authority.Plugin.Standard.csproj", "{93DB06DC-B254-48A9-8F2C-6130A5658F27}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Plugin", "src\StellaOps.Plugin\StellaOps.Plugin.csproj", "{03CA315C-8AA1-4CEA-A28B-5EB35C586F4A}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Plugin", "StellaOps.Plugin\StellaOps.Plugin.csproj", "{03CA315C-8AA1-4CEA-A28B-5EB35C586F4A}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cli", "src\StellaOps.Cli\StellaOps.Cli.csproj", "{40094279-250C-42AE-992A-856718FEFBAC}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cli", "StellaOps.Cli\StellaOps.Cli.csproj", "{40094279-250C-42AE-992A-856718FEFBAC}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cli.Tests", "src\StellaOps.Cli.Tests\StellaOps.Cli.Tests.csproj", "{B2967228-F8F7-4931-B257-1C63CB58CE1D}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cli.Tests", "StellaOps.Cli.Tests\StellaOps.Cli.Tests.csproj", "{B2967228-F8F7-4931-B257-1C63CB58CE1D}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Testing", "src\StellaOps.Feedser.Testing\StellaOps.Feedser.Testing.csproj", "{6D52EC2B-0A1A-4693-A8EE-5AB32A4A3ED9}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Testing", "StellaOps.Feedser.Testing\StellaOps.Feedser.Testing.csproj", "{6D52EC2B-0A1A-4693-A8EE-5AB32A4A3ED9}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Common", "src\StellaOps.Feedser.Source.Common\StellaOps.Feedser.Source.Common.csproj", "{37F203A3-624E-4794-9C99-16CAC22C17DF}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Common", "StellaOps.Feedser.Source.Common\StellaOps.Feedser.Source.Common.csproj", "{37F203A3-624E-4794-9C99-16CAC22C17DF}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Storage.Mongo", "src\StellaOps.Feedser.Storage.Mongo\StellaOps.Feedser.Storage.Mongo.csproj", "{3FF93987-A30A-4D50-8815-7CF3BB7CAE05}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Storage.Mongo", "StellaOps.Feedser.Storage.Mongo\StellaOps.Feedser.Storage.Mongo.csproj", "{3FF93987-A30A-4D50-8815-7CF3BB7CAE05}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Core", "src\StellaOps.Feedser.Core\StellaOps.Feedser.Core.csproj", "{AACE8717-0760-42F2-A225-8FCCE876FB65}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Core", "StellaOps.Feedser.Core\StellaOps.Feedser.Core.csproj", "{AACE8717-0760-42F2-A225-8FCCE876FB65}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Models", "src\StellaOps.Feedser.Models\StellaOps.Feedser.Models.csproj", "{4AAD6965-E879-44AD-A8ED-E1D713A3CD6D}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Models", "StellaOps.Feedser.Models\StellaOps.Feedser.Models.csproj", "{4AAD6965-E879-44AD-A8ED-E1D713A3CD6D}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Normalization", "src\StellaOps.Feedser.Normalization\StellaOps.Feedser.Normalization.csproj", "{85D82A87-1F4A-4B1B-8422-5B7A7B7704E3}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Normalization", "StellaOps.Feedser.Normalization\StellaOps.Feedser.Normalization.csproj", "{85D82A87-1F4A-4B1B-8422-5B7A7B7704E3}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Core.Tests", "src\StellaOps.Feedser.Core.Tests\StellaOps.Feedser.Core.Tests.csproj", "{FE227DF2-875D-4BEA-A4E0-14EA7F3EC1D0}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Core.Tests", "StellaOps.Feedser.Core.Tests\StellaOps.Feedser.Core.Tests.csproj", "{FE227DF2-875D-4BEA-A4E0-14EA7F3EC1D0}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Exporter.Json", "src\StellaOps.Feedser.Exporter.Json\StellaOps.Feedser.Exporter.Json.csproj", "{D0FB54BA-4D14-4A32-B09F-7EC94F369460}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Exporter.Json", "StellaOps.Feedser.Exporter.Json\StellaOps.Feedser.Exporter.Json.csproj", "{D0FB54BA-4D14-4A32-B09F-7EC94F369460}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Exporter.Json.Tests", "src\StellaOps.Feedser.Exporter.Json.Tests\StellaOps.Feedser.Exporter.Json.Tests.csproj", "{69C9E010-CBDD-4B89-84CF-7AB56D6A078A}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Exporter.Json.Tests", "StellaOps.Feedser.Exporter.Json.Tests\StellaOps.Feedser.Exporter.Json.Tests.csproj", "{69C9E010-CBDD-4B89-84CF-7AB56D6A078A}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Exporter.TrivyDb", "src\StellaOps.Feedser.Exporter.TrivyDb\StellaOps.Feedser.Exporter.TrivyDb.csproj", "{E471176A-E1F3-4DE5-8D30-0865903A217A}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Exporter.TrivyDb", "StellaOps.Feedser.Exporter.TrivyDb\StellaOps.Feedser.Exporter.TrivyDb.csproj", "{E471176A-E1F3-4DE5-8D30-0865903A217A}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Exporter.TrivyDb.Tests", "src\StellaOps.Feedser.Exporter.TrivyDb.Tests\StellaOps.Feedser.Exporter.TrivyDb.Tests.csproj", "{FA013511-DF20-45F7-8077-EBA2D6224D64}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Exporter.TrivyDb.Tests", "StellaOps.Feedser.Exporter.TrivyDb.Tests\StellaOps.Feedser.Exporter.TrivyDb.Tests.csproj", "{FA013511-DF20-45F7-8077-EBA2D6224D64}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Merge", "src\StellaOps.Feedser.Merge\StellaOps.Feedser.Merge.csproj", "{B9F84697-54FE-4648-B173-EE3D904FFA4D}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Merge", "StellaOps.Feedser.Merge\StellaOps.Feedser.Merge.csproj", "{B9F84697-54FE-4648-B173-EE3D904FFA4D}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Merge.Tests", "src\StellaOps.Feedser.Merge.Tests\StellaOps.Feedser.Merge.Tests.csproj", "{6751A76C-8ED8-40F4-AE2B-069DB31395FE}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Merge.Tests", "StellaOps.Feedser.Merge.Tests\StellaOps.Feedser.Merge.Tests.csproj", "{6751A76C-8ED8-40F4-AE2B-069DB31395FE}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Models.Tests", "src\StellaOps.Feedser.Models.Tests\StellaOps.Feedser.Models.Tests.csproj", "{DDBFA2EF-9CAE-473F-A438-369CAC25C66A}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Models.Tests", "StellaOps.Feedser.Models.Tests\StellaOps.Feedser.Models.Tests.csproj", "{DDBFA2EF-9CAE-473F-A438-369CAC25C66A}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Normalization.Tests", "src\StellaOps.Feedser.Normalization.Tests\StellaOps.Feedser.Normalization.Tests.csproj", "{063DE5E1-C8FE-47D0-A12A-22A25CDF2C22}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Normalization.Tests", "StellaOps.Feedser.Normalization.Tests\StellaOps.Feedser.Normalization.Tests.csproj", "{063DE5E1-C8FE-47D0-A12A-22A25CDF2C22}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Acsc", "src\StellaOps.Feedser.Source.Acsc\StellaOps.Feedser.Source.Acsc.csproj", "{35350FAB-FC51-4FE8-81FB-011003134C37}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Acsc", "StellaOps.Feedser.Source.Acsc\StellaOps.Feedser.Source.Acsc.csproj", "{35350FAB-FC51-4FE8-81FB-011003134C37}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Cccs", "src\StellaOps.Feedser.Source.Cccs\StellaOps.Feedser.Source.Cccs.csproj", "{1BFC95B4-4C8A-44B2-903A-11FBCAAB9519}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Cccs", "StellaOps.Feedser.Source.Cccs\StellaOps.Feedser.Source.Cccs.csproj", "{1BFC95B4-4C8A-44B2-903A-11FBCAAB9519}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.CertBund", "src\StellaOps.Feedser.Source.CertBund\StellaOps.Feedser.Source.CertBund.csproj", "{C4A65377-22F7-4D15-92A3-4F05847D167E}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.CertBund", "StellaOps.Feedser.Source.CertBund\StellaOps.Feedser.Source.CertBund.csproj", "{C4A65377-22F7-4D15-92A3-4F05847D167E}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.CertCc", "src\StellaOps.Feedser.Source.CertCc\StellaOps.Feedser.Source.CertCc.csproj", "{BDDE59E1-C643-4C87-8608-0F9A7A54DE09}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.CertCc", "StellaOps.Feedser.Source.CertCc\StellaOps.Feedser.Source.CertCc.csproj", "{BDDE59E1-C643-4C87-8608-0F9A7A54DE09}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.CertFr", "src\StellaOps.Feedser.Source.CertFr\StellaOps.Feedser.Source.CertFr.csproj", "{0CC116C8-A7E5-4B94-9688-32920177FF97}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.CertFr", "StellaOps.Feedser.Source.CertFr\StellaOps.Feedser.Source.CertFr.csproj", "{0CC116C8-A7E5-4B94-9688-32920177FF97}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.CertFr.Tests", "src\StellaOps.Feedser.Source.CertFr.Tests\StellaOps.Feedser.Source.CertFr.Tests.csproj", "{E8862F6E-85C1-4FDB-AA92-0BB489B7EA1E}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.CertFr.Tests", "StellaOps.Feedser.Source.CertFr.Tests\StellaOps.Feedser.Source.CertFr.Tests.csproj", "{E8862F6E-85C1-4FDB-AA92-0BB489B7EA1E}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.CertIn", "src\StellaOps.Feedser.Source.CertIn\StellaOps.Feedser.Source.CertIn.csproj", "{84DEDF05-A5BD-4644-86B9-6B7918FE3F31}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.CertIn", "StellaOps.Feedser.Source.CertIn\StellaOps.Feedser.Source.CertIn.csproj", "{84DEDF05-A5BD-4644-86B9-6B7918FE3F31}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.CertIn.Tests", "src\StellaOps.Feedser.Source.CertIn.Tests\StellaOps.Feedser.Source.CertIn.Tests.csproj", "{9DEB1F54-94B5-40C4-AC44-220E680B016D}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.CertIn.Tests", "StellaOps.Feedser.Source.CertIn.Tests\StellaOps.Feedser.Source.CertIn.Tests.csproj", "{9DEB1F54-94B5-40C4-AC44-220E680B016D}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Common.Tests", "src\StellaOps.Feedser.Source.Common.Tests\StellaOps.Feedser.Source.Common.Tests.csproj", "{7C3E87F2-93D8-4968-95E3-52C46947D46C}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Common.Tests", "StellaOps.Feedser.Source.Common.Tests\StellaOps.Feedser.Source.Common.Tests.csproj", "{7C3E87F2-93D8-4968-95E3-52C46947D46C}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Cve", "src\StellaOps.Feedser.Source.Cve\StellaOps.Feedser.Source.Cve.csproj", "{C0504D97-9BCD-4AE4-B0DC-B31C17B150F2}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Cve", "StellaOps.Feedser.Source.Cve\StellaOps.Feedser.Source.Cve.csproj", "{C0504D97-9BCD-4AE4-B0DC-B31C17B150F2}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Distro.Debian", "src\StellaOps.Feedser.Source.Distro.Debian\StellaOps.Feedser.Source.Distro.Debian.csproj", "{31B05493-104F-437F-9FA7-CA5286CE697C}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Distro.Debian", "StellaOps.Feedser.Source.Distro.Debian\StellaOps.Feedser.Source.Distro.Debian.csproj", "{31B05493-104F-437F-9FA7-CA5286CE697C}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Distro.Debian.Tests", "src\StellaOps.Feedser.Source.Distro.Debian.Tests\StellaOps.Feedser.Source.Distro.Debian.Tests.csproj", "{937AF12E-D770-4534-8FF8-C59042609C2A}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Distro.Debian.Tests", "StellaOps.Feedser.Source.Distro.Debian.Tests\StellaOps.Feedser.Source.Distro.Debian.Tests.csproj", "{937AF12E-D770-4534-8FF8-C59042609C2A}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Distro.RedHat", "src\StellaOps.Feedser.Source.Distro.RedHat\StellaOps.Feedser.Source.Distro.RedHat.csproj", "{5A028B04-9D76-470B-B5B3-766CE4CE860C}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Distro.RedHat", "StellaOps.Feedser.Source.Distro.RedHat\StellaOps.Feedser.Source.Distro.RedHat.csproj", "{5A028B04-9D76-470B-B5B3-766CE4CE860C}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Distro.RedHat.Tests", "src\StellaOps.Feedser.Source.Distro.RedHat.Tests\StellaOps.Feedser.Source.Distro.RedHat.Tests.csproj", "{749DE4C8-F733-43F8-B2A8-6649E71C7570}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Distro.RedHat.Tests", "StellaOps.Feedser.Source.Distro.RedHat.Tests\StellaOps.Feedser.Source.Distro.RedHat.Tests.csproj", "{749DE4C8-F733-43F8-B2A8-6649E71C7570}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Distro.Suse", "src\StellaOps.Feedser.Source.Distro.Suse\StellaOps.Feedser.Source.Distro.Suse.csproj", "{56D2C79E-2737-4FF9-9D19-150065F568D5}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Distro.Suse", "StellaOps.Feedser.Source.Distro.Suse\StellaOps.Feedser.Source.Distro.Suse.csproj", "{56D2C79E-2737-4FF9-9D19-150065F568D5}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Distro.Suse.Tests", "src\StellaOps.Feedser.Source.Distro.Suse.Tests\StellaOps.Feedser.Source.Distro.Suse.Tests.csproj", "{E41F6DC4-68B5-4EE3-97AE-801D725A2C13}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Distro.Suse.Tests", "StellaOps.Feedser.Source.Distro.Suse.Tests\StellaOps.Feedser.Source.Distro.Suse.Tests.csproj", "{E41F6DC4-68B5-4EE3-97AE-801D725A2C13}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Distro.Ubuntu", "src\StellaOps.Feedser.Source.Distro.Ubuntu\StellaOps.Feedser.Source.Distro.Ubuntu.csproj", "{285F1D0F-501F-4E2E-8FA0-F2CF28AE3798}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Distro.Ubuntu", "StellaOps.Feedser.Source.Distro.Ubuntu\StellaOps.Feedser.Source.Distro.Ubuntu.csproj", "{285F1D0F-501F-4E2E-8FA0-F2CF28AE3798}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Distro.Ubuntu.Tests", "src\StellaOps.Feedser.Source.Distro.Ubuntu.Tests\StellaOps.Feedser.Source.Distro.Ubuntu.Tests.csproj", "{26055403-C7F5-4709-8813-0F7387102791}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Distro.Ubuntu.Tests", "StellaOps.Feedser.Source.Distro.Ubuntu.Tests\StellaOps.Feedser.Source.Distro.Ubuntu.Tests.csproj", "{26055403-C7F5-4709-8813-0F7387102791}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Ghsa", "src\StellaOps.Feedser.Source.Ghsa\StellaOps.Feedser.Source.Ghsa.csproj", "{0C00D0DA-C4C3-4B23-941F-A3DB2DBF33AF}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Ghsa", "StellaOps.Feedser.Source.Ghsa\StellaOps.Feedser.Source.Ghsa.csproj", "{0C00D0DA-C4C3-4B23-941F-A3DB2DBF33AF}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Ics.Cisa", "src\StellaOps.Feedser.Source.Ics.Cisa\StellaOps.Feedser.Source.Ics.Cisa.csproj", "{258327E9-431E-475C-933B-50893676E452}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Ics.Cisa", "StellaOps.Feedser.Source.Ics.Cisa\StellaOps.Feedser.Source.Ics.Cisa.csproj", "{258327E9-431E-475C-933B-50893676E452}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Ics.Kaspersky", "src\StellaOps.Feedser.Source.Ics.Kaspersky\StellaOps.Feedser.Source.Ics.Kaspersky.csproj", "{42AF60C8-A5E1-40E0-86F8-98256364AF6F}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Ics.Kaspersky", "StellaOps.Feedser.Source.Ics.Kaspersky\StellaOps.Feedser.Source.Ics.Kaspersky.csproj", "{42AF60C8-A5E1-40E0-86F8-98256364AF6F}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Ics.Kaspersky.Tests", "src\StellaOps.Feedser.Source.Ics.Kaspersky.Tests\StellaOps.Feedser.Source.Ics.Kaspersky.Tests.csproj", "{88C6A9C3-B433-4C36-8767-429C8C2396F8}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Ics.Kaspersky.Tests", "StellaOps.Feedser.Source.Ics.Kaspersky.Tests\StellaOps.Feedser.Source.Ics.Kaspersky.Tests.csproj", "{88C6A9C3-B433-4C36-8767-429C8C2396F8}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Jvn", "src\StellaOps.Feedser.Source.Jvn\StellaOps.Feedser.Source.Jvn.csproj", "{6B7099AB-01BF-4EC4-87D0-5C9C032266DE}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Jvn", "StellaOps.Feedser.Source.Jvn\StellaOps.Feedser.Source.Jvn.csproj", "{6B7099AB-01BF-4EC4-87D0-5C9C032266DE}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Jvn.Tests", "src\StellaOps.Feedser.Source.Jvn.Tests\StellaOps.Feedser.Source.Jvn.Tests.csproj", "{14C918EA-693E-41FE-ACAE-2E82DF077BEA}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Jvn.Tests", "StellaOps.Feedser.Source.Jvn.Tests\StellaOps.Feedser.Source.Jvn.Tests.csproj", "{14C918EA-693E-41FE-ACAE-2E82DF077BEA}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Kev", "src\StellaOps.Feedser.Source.Kev\StellaOps.Feedser.Source.Kev.csproj", "{81111B26-74F6-4912-9084-7115FD119945}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Kev", "StellaOps.Feedser.Source.Kev\StellaOps.Feedser.Source.Kev.csproj", "{81111B26-74F6-4912-9084-7115FD119945}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Kisa", "src\StellaOps.Feedser.Source.Kisa\StellaOps.Feedser.Source.Kisa.csproj", "{80E2D661-FF3E-4A10-A2DF-AFD4F3D433FE}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Kisa", "StellaOps.Feedser.Source.Kisa\StellaOps.Feedser.Source.Kisa.csproj", "{80E2D661-FF3E-4A10-A2DF-AFD4F3D433FE}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Nvd", "src\StellaOps.Feedser.Source.Nvd\StellaOps.Feedser.Source.Nvd.csproj", "{8D0F501D-01B1-4E24-958B-FAF35B267705}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Nvd", "StellaOps.Feedser.Source.Nvd\StellaOps.Feedser.Source.Nvd.csproj", "{8D0F501D-01B1-4E24-958B-FAF35B267705}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Nvd.Tests", "src\StellaOps.Feedser.Source.Nvd.Tests\StellaOps.Feedser.Source.Nvd.Tests.csproj", "{5BA91095-7F10-4717-B296-49DFBFC1C9C2}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Nvd.Tests", "StellaOps.Feedser.Source.Nvd.Tests\StellaOps.Feedser.Source.Nvd.Tests.csproj", "{5BA91095-7F10-4717-B296-49DFBFC1C9C2}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Osv", "src\StellaOps.Feedser.Source.Osv\StellaOps.Feedser.Source.Osv.csproj", "{99616566-4EF1-4DC7-B655-825FE43D203D}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Osv", "StellaOps.Feedser.Source.Osv\StellaOps.Feedser.Source.Osv.csproj", "{99616566-4EF1-4DC7-B655-825FE43D203D}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Osv.Tests", "src\StellaOps.Feedser.Source.Osv.Tests\StellaOps.Feedser.Source.Osv.Tests.csproj", "{EE3C03AD-E604-4C57-9B78-CF7F49FBFCB0}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Osv.Tests", "StellaOps.Feedser.Source.Osv.Tests\StellaOps.Feedser.Source.Osv.Tests.csproj", "{EE3C03AD-E604-4C57-9B78-CF7F49FBFCB0}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Ru.Bdu", "src\StellaOps.Feedser.Source.Ru.Bdu\StellaOps.Feedser.Source.Ru.Bdu.csproj", "{A3B19095-2D95-4B09-B07E-2C082C72394B}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Ru.Bdu", "StellaOps.Feedser.Source.Ru.Bdu\StellaOps.Feedser.Source.Ru.Bdu.csproj", "{A3B19095-2D95-4B09-B07E-2C082C72394B}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Ru.Nkcki", "src\StellaOps.Feedser.Source.Ru.Nkcki\StellaOps.Feedser.Source.Ru.Nkcki.csproj", "{807837AF-B392-4589-ADF1-3FDB34D6C5BF}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Ru.Nkcki", "StellaOps.Feedser.Source.Ru.Nkcki\StellaOps.Feedser.Source.Ru.Nkcki.csproj", "{807837AF-B392-4589-ADF1-3FDB34D6C5BF}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Vndr.Adobe", "src\StellaOps.Feedser.Source.Vndr.Adobe\StellaOps.Feedser.Source.Vndr.Adobe.csproj", "{64EAFDCF-8283-4D5C-AC78-7969D5FE926A}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Vndr.Adobe", "StellaOps.Feedser.Source.Vndr.Adobe\StellaOps.Feedser.Source.Vndr.Adobe.csproj", "{64EAFDCF-8283-4D5C-AC78-7969D5FE926A}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Vndr.Adobe.Tests", "src\StellaOps.Feedser.Source.Vndr.Adobe.Tests\StellaOps.Feedser.Source.Vndr.Adobe.Tests.csproj", "{68F4D8A1-E32F-487A-B460-325F36989BE3}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Vndr.Adobe.Tests", "StellaOps.Feedser.Source.Vndr.Adobe.Tests\StellaOps.Feedser.Source.Vndr.Adobe.Tests.csproj", "{68F4D8A1-E32F-487A-B460-325F36989BE3}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Vndr.Apple", "src\StellaOps.Feedser.Source.Vndr.Apple\StellaOps.Feedser.Source.Vndr.Apple.csproj", "{4A3DA4AE-7B88-4674-A7E2-F5D42B8256F2}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Vndr.Apple", "StellaOps.Feedser.Source.Vndr.Apple\StellaOps.Feedser.Source.Vndr.Apple.csproj", "{4A3DA4AE-7B88-4674-A7E2-F5D42B8256F2}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Vndr.Chromium", "src\StellaOps.Feedser.Source.Vndr.Chromium\StellaOps.Feedser.Source.Vndr.Chromium.csproj", "{606C751B-7CF1-47CF-A25C-9248A55C814F}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Vndr.Chromium", "StellaOps.Feedser.Source.Vndr.Chromium\StellaOps.Feedser.Source.Vndr.Chromium.csproj", "{606C751B-7CF1-47CF-A25C-9248A55C814F}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Vndr.Chromium.Tests", "src\StellaOps.Feedser.Source.Vndr.Chromium.Tests\StellaOps.Feedser.Source.Vndr.Chromium.Tests.csproj", "{0BE44D0A-CC4B-4E84-8AF3-D8D99551C431}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Vndr.Chromium.Tests", "StellaOps.Feedser.Source.Vndr.Chromium.Tests\StellaOps.Feedser.Source.Vndr.Chromium.Tests.csproj", "{0BE44D0A-CC4B-4E84-8AF3-D8D99551C431}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Vndr.Cisco", "src\StellaOps.Feedser.Source.Vndr.Cisco\StellaOps.Feedser.Source.Vndr.Cisco.csproj", "{CC4CCE5F-55BC-4745-A204-4FA92BC1BADC}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Vndr.Cisco", "StellaOps.Feedser.Source.Vndr.Cisco\StellaOps.Feedser.Source.Vndr.Cisco.csproj", "{CC4CCE5F-55BC-4745-A204-4FA92BC1BADC}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Vndr.Msrc", "src\StellaOps.Feedser.Source.Vndr.Msrc\StellaOps.Feedser.Source.Vndr.Msrc.csproj", "{5CCE0DB7-C115-4B21-A7AE-C8488C22A853}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Vndr.Msrc", "StellaOps.Feedser.Source.Vndr.Msrc\StellaOps.Feedser.Source.Vndr.Msrc.csproj", "{5CCE0DB7-C115-4B21-A7AE-C8488C22A853}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Vndr.Oracle", "src\StellaOps.Feedser.Source.Vndr.Oracle\StellaOps.Feedser.Source.Vndr.Oracle.csproj", "{A09C9E66-5496-47EC-8B23-9EEB7CBDC75E}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Vndr.Oracle", "StellaOps.Feedser.Source.Vndr.Oracle\StellaOps.Feedser.Source.Vndr.Oracle.csproj", "{A09C9E66-5496-47EC-8B23-9EEB7CBDC75E}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Vndr.Oracle.Tests", "src\StellaOps.Feedser.Source.Vndr.Oracle.Tests\StellaOps.Feedser.Source.Vndr.Oracle.Tests.csproj", "{06DC817F-A936-4F83-8929-E00622B32245}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Vndr.Oracle.Tests", "StellaOps.Feedser.Source.Vndr.Oracle.Tests\StellaOps.Feedser.Source.Vndr.Oracle.Tests.csproj", "{06DC817F-A936-4F83-8929-E00622B32245}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Vndr.Vmware", "src\StellaOps.Feedser.Source.Vndr.Vmware\StellaOps.Feedser.Source.Vndr.Vmware.csproj", "{2C999476-0291-4161-B3E9-1AA99A3B1139}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Vndr.Vmware", "StellaOps.Feedser.Source.Vndr.Vmware\StellaOps.Feedser.Source.Vndr.Vmware.csproj", "{2C999476-0291-4161-B3E9-1AA99A3B1139}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Vndr.Vmware.Tests", "src\StellaOps.Feedser.Source.Vndr.Vmware.Tests\StellaOps.Feedser.Source.Vndr.Vmware.Tests.csproj", "{476EAADA-1B39-4049-ABE4-CCAC21FFE9E2}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Vndr.Vmware.Tests", "StellaOps.Feedser.Source.Vndr.Vmware.Tests\StellaOps.Feedser.Source.Vndr.Vmware.Tests.csproj", "{476EAADA-1B39-4049-ABE4-CCAC21FFE9E2}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Storage.Mongo.Tests", "src\StellaOps.Feedser.Storage.Mongo.Tests\StellaOps.Feedser.Storage.Mongo.Tests.csproj", "{0EF56124-E6E8-4E89-95DD-5A5D5FF05A98}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Storage.Mongo.Tests", "StellaOps.Feedser.Storage.Mongo.Tests\StellaOps.Feedser.Storage.Mongo.Tests.csproj", "{0EF56124-E6E8-4E89-95DD-5A5D5FF05A98}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.WebService", "src\StellaOps.Feedser.WebService\StellaOps.Feedser.WebService.csproj", "{0DBB9FC4-2E46-4C3E-BE88-2A8DCB59DB7D}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.WebService", "StellaOps.Feedser.WebService\StellaOps.Feedser.WebService.csproj", "{0DBB9FC4-2E46-4C3E-BE88-2A8DCB59DB7D}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.WebService.Tests", "src\StellaOps.Feedser.WebService.Tests\StellaOps.Feedser.WebService.Tests.csproj", "{8A40142F-E8C8-4E86-BE70-7DD4AB1FFDEE}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.WebService.Tests", "StellaOps.Feedser.WebService.Tests\StellaOps.Feedser.WebService.Tests.csproj", "{8A40142F-E8C8-4E86-BE70-7DD4AB1FFDEE}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Configuration.Tests", "src\StellaOps.Configuration.Tests\StellaOps.Configuration.Tests.csproj", "{C9D20F74-EE5F-4C9E-9AB1-C03E90B34F92}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Configuration.Tests", "StellaOps.Configuration.Tests\StellaOps.Configuration.Tests.csproj", "{C9D20F74-EE5F-4C9E-9AB1-C03E90B34F92}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugins.Abstractions.Tests", "src\StellaOps.Authority\StellaOps.Authority.Plugins.Abstractions.Tests\StellaOps.Authority.Plugins.Abstractions.Tests.csproj", "{50140A32-6D3C-47DB-983A-7166CBA51845}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugins.Abstractions.Tests", "StellaOps.Authority\StellaOps.Authority.Plugins.Abstractions.Tests\StellaOps.Authority.Plugins.Abstractions.Tests.csproj", "{50140A32-6D3C-47DB-983A-7166CBA51845}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Tests", "src\StellaOps.Authority\StellaOps.Authority.Tests\StellaOps.Authority.Tests.csproj", "{031979F2-6ABA-444F-A6A4-80115DC487CE}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Tests", "StellaOps.Authority\StellaOps.Authority.Tests\StellaOps.Authority.Tests.csproj", "{031979F2-6ABA-444F-A6A4-80115DC487CE}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugin.Standard.Tests", "src\StellaOps.Authority\StellaOps.Authority.Plugin.Standard.Tests\StellaOps.Authority.Plugin.Standard.Tests.csproj", "{D71B0DA5-80A3-419E-898D-40E77A9A7F19}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugin.Standard.Tests", "StellaOps.Authority\StellaOps.Authority.Plugin.Standard.Tests\StellaOps.Authority.Plugin.Standard.Tests.csproj", "{D71B0DA5-80A3-419E-898D-40E77A9A7F19}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Storage.Mongo", "src\StellaOps.Authority\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj", "{B2C877D9-B521-4901-8817-76B5DAA62FCE}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Storage.Mongo", "StellaOps.Authority\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj", "{B2C877D9-B521-4901-8817-76B5DAA62FCE}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Abstractions.Tests", "src\StellaOps.Authority\StellaOps.Auth.Abstractions.Tests\StellaOps.Auth.Abstractions.Tests.csproj", "{08D3B6D0-3CE8-4F24-A6F1-BCAB01AD6278}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Abstractions.Tests", "StellaOps.Authority\StellaOps.Auth.Abstractions.Tests\StellaOps.Auth.Abstractions.Tests.csproj", "{08D3B6D0-3CE8-4F24-A6F1-BCAB01AD6278}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.ServerIntegration.Tests", "src\StellaOps.Authority\StellaOps.Auth.ServerIntegration.Tests\StellaOps.Auth.ServerIntegration.Tests.csproj", "{7116DD6B-2491-49E1-AB27-5210E949F753}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.ServerIntegration.Tests", "StellaOps.Authority\StellaOps.Auth.ServerIntegration.Tests\StellaOps.Auth.ServerIntegration.Tests.csproj", "{7116DD6B-2491-49E1-AB27-5210E949F753}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Client.Tests", "src\StellaOps.Authority\StellaOps.Auth.Client.Tests\StellaOps.Auth.Client.Tests.csproj", "{7DBE31A6-D2FD-499E-B675-4092723175AD}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Client.Tests", "StellaOps.Authority\StellaOps.Auth.Client.Tests\StellaOps.Auth.Client.Tests.csproj", "{7DBE31A6-D2FD-499E-B675-4092723175AD}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Kev.Tests", "src\StellaOps.Feedser.Source.Kev.Tests\StellaOps.Feedser.Source.Kev.Tests.csproj", "{D99E6EAE-D278-4480-AA67-85F025383E47}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Kev.Tests", "StellaOps.Feedser.Source.Kev.Tests\StellaOps.Feedser.Source.Kev.Tests.csproj", "{D99E6EAE-D278-4480-AA67-85F025383E47}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Cve.Tests", "src\StellaOps.Feedser.Source.Cve.Tests\StellaOps.Feedser.Source.Cve.Tests.csproj", "{D3825714-3DDA-44B7-A99C-5F3E65716691}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Cve.Tests", "StellaOps.Feedser.Source.Cve.Tests\StellaOps.Feedser.Source.Cve.Tests.csproj", "{D3825714-3DDA-44B7-A99C-5F3E65716691}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Ghsa.Tests", "src\StellaOps.Feedser.Source.Ghsa.Tests\StellaOps.Feedser.Source.Ghsa.Tests.csproj", "{FAB78D21-7372-48FE-B2C3-DE1807F1157D}" | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Source.Ghsa.Tests", "StellaOps.Feedser.Source.Ghsa.Tests\StellaOps.Feedser.Source.Ghsa.Tests.csproj", "{FAB78D21-7372-48FE-B2C3-DE1807F1157D}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{EADFA337-B0FA-4712-A24A-7C08235BDF98}" | ||||
| EndProject | ||||
| Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Tests", "StellaOps.Cryptography.Tests\StellaOps.Cryptography.Tests.csproj", "{110F7EC2-3149-4D1B-A972-E69E79F1EBF5}" | ||||
| EndProject | ||||
| Global | ||||
| 	GlobalSection(SolutionConfigurationPlatforms) = preSolution | ||||
| @@ -1179,6 +1183,30 @@ Global | ||||
| 		{FAB78D21-7372-48FE-B2C3-DE1807F1157D}.Release|x64.Build.0 = Release|Any CPU | ||||
| 		{FAB78D21-7372-48FE-B2C3-DE1807F1157D}.Release|x86.ActiveCfg = Release|Any CPU | ||||
| 		{FAB78D21-7372-48FE-B2C3-DE1807F1157D}.Release|x86.Build.0 = Release|Any CPU | ||||
| 		{EADFA337-B0FA-4712-A24A-7C08235BDF98}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | ||||
| 		{EADFA337-B0FA-4712-A24A-7C08235BDF98}.Debug|Any CPU.Build.0 = Debug|Any CPU | ||||
| 		{EADFA337-B0FA-4712-A24A-7C08235BDF98}.Debug|x64.ActiveCfg = Debug|Any CPU | ||||
| 		{EADFA337-B0FA-4712-A24A-7C08235BDF98}.Debug|x64.Build.0 = Debug|Any CPU | ||||
| 		{EADFA337-B0FA-4712-A24A-7C08235BDF98}.Debug|x86.ActiveCfg = Debug|Any CPU | ||||
| 		{EADFA337-B0FA-4712-A24A-7C08235BDF98}.Debug|x86.Build.0 = Debug|Any CPU | ||||
| 		{EADFA337-B0FA-4712-A24A-7C08235BDF98}.Release|Any CPU.ActiveCfg = Release|Any CPU | ||||
| 		{EADFA337-B0FA-4712-A24A-7C08235BDF98}.Release|Any CPU.Build.0 = Release|Any CPU | ||||
| 		{EADFA337-B0FA-4712-A24A-7C08235BDF98}.Release|x64.ActiveCfg = Release|Any CPU | ||||
| 		{EADFA337-B0FA-4712-A24A-7C08235BDF98}.Release|x64.Build.0 = Release|Any CPU | ||||
| 		{EADFA337-B0FA-4712-A24A-7C08235BDF98}.Release|x86.ActiveCfg = Release|Any CPU | ||||
| 		{EADFA337-B0FA-4712-A24A-7C08235BDF98}.Release|x86.Build.0 = Release|Any CPU | ||||
| 		{110F7EC2-3149-4D1B-A972-E69E79F1EBF5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | ||||
| 		{110F7EC2-3149-4D1B-A972-E69E79F1EBF5}.Debug|Any CPU.Build.0 = Debug|Any CPU | ||||
| 		{110F7EC2-3149-4D1B-A972-E69E79F1EBF5}.Debug|x64.ActiveCfg = Debug|Any CPU | ||||
| 		{110F7EC2-3149-4D1B-A972-E69E79F1EBF5}.Debug|x64.Build.0 = Debug|Any CPU | ||||
| 		{110F7EC2-3149-4D1B-A972-E69E79F1EBF5}.Debug|x86.ActiveCfg = Debug|Any CPU | ||||
| 		{110F7EC2-3149-4D1B-A972-E69E79F1EBF5}.Debug|x86.Build.0 = Debug|Any CPU | ||||
| 		{110F7EC2-3149-4D1B-A972-E69E79F1EBF5}.Release|Any CPU.ActiveCfg = Release|Any CPU | ||||
| 		{110F7EC2-3149-4D1B-A972-E69E79F1EBF5}.Release|Any CPU.Build.0 = Release|Any CPU | ||||
| 		{110F7EC2-3149-4D1B-A972-E69E79F1EBF5}.Release|x64.ActiveCfg = Release|Any CPU | ||||
| 		{110F7EC2-3149-4D1B-A972-E69E79F1EBF5}.Release|x64.Build.0 = Release|Any CPU | ||||
| 		{110F7EC2-3149-4D1B-A972-E69E79F1EBF5}.Release|x86.ActiveCfg = Release|Any CPU | ||||
| 		{110F7EC2-3149-4D1B-A972-E69E79F1EBF5}.Release|x86.Build.0 = Release|Any CPU | ||||
| 	EndGlobalSection | ||||
| 	GlobalSection(SolutionProperties) = preSolution | ||||
| 		HideSolutionNode = FALSE | ||||
| @@ -1268,5 +1296,7 @@ Global | ||||
| 		{D99E6EAE-D278-4480-AA67-85F025383E47} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} | ||||
| 		{D3825714-3DDA-44B7-A99C-5F3E65716691} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} | ||||
| 		{FAB78D21-7372-48FE-B2C3-DE1807F1157D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} | ||||
| 		{EADFA337-B0FA-4712-A24A-7C08235BDF98} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} | ||||
| 		{110F7EC2-3149-4D1B-A972-E69E79F1EBF5} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} | ||||
| 	EndGlobalSection | ||||
| EndGlobal | ||||
		Reference in New Issue
	
	Block a user