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.
|
||||
|
||||
@@ -140,10 +140,25 @@ cosign verify ghcr.io/stellaops/backend@sha256:<DIGEST> \
|
||||
|
||||
| Control | Implementation |
|
||||
| ------------ | ----------------------------------------------------------------- |
|
||||
| Log format | Serilog JSON; ship via Fluent‑Bit to ELK or Loki |
|
||||
| Metrics | Prometheus /metrics endpoint; default Grafana dashboard in infra/ |
|
||||
| Audit events | Redis stream audit; export daily to SIEM |
|
||||
| Alert rules | Feed age ≥ 48 h, P95 wall‑time > 5 s, Redis used memory > 75 % |
|
||||
| Log format | Serilog JSON; ship via Fluent‑Bit to ELK or Loki |
|
||||
| Metrics | Prometheus /metrics endpoint; default Grafana dashboard in infra/ |
|
||||
| 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
|
||||
|
||||
|
||||
@@ -65,20 +65,58 @@ $EDITOR .env
|
||||
# 5. Launch databases (MongoDB + Redis)
|
||||
docker compose --env-file .env -f docker-compose.infrastructure.yml up -d
|
||||
|
||||
# 6. Launch Stella Ops (first run pulls ~50 MB merged vuln DB)
|
||||
docker compose --env-file .env -f docker-compose.stella-ops.yml up -d
|
||||
````
|
||||
|
||||
*Default login:* `admin / changeme`
|
||||
UI: [https://\<host\>:8443](https://<host>:8443) (self‑signed certificate)
|
||||
|
||||
> **Pinning best‑practice** – in production environments replace
|
||||
> `stella-ops:latest` with the immutable digest printed by
|
||||
> `docker images --digests`.
|
||||
|
||||
---
|
||||
|
||||
## 2 · Optional: request a free quota token
|
||||
# 6. Launch Stella Ops (first run pulls ~50 MB merged vuln DB)
|
||||
docker compose --env-file .env -f docker-compose.stella-ops.yml up -d
|
||||
````
|
||||
|
||||
*Default login:* `admin / changeme`
|
||||
UI: [https://\<host\>:8443](https://<host>:8443) (self‑signed certificate)
|
||||
|
||||
> **Pinning best‑practice** – in production environments replace
|
||||
> `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
|
||||
|
||||
Anonymous installs allow **{{ quota\_anon }} scans per UTC day**.
|
||||
Email `token@stella-ops.org` to receive a signed JWT that raises the limit to
|
||||
|
||||
19
docs/AGENTS.md
Normal file
19
docs/AGENTS.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Docs & Enablement Guild
|
||||
|
||||
## Mission
|
||||
Produce and maintain offline-friendly documentation for StellaOps modules, covering architecture, configuration, operator workflows, and developer onboarding.
|
||||
|
||||
## Scope Highlights
|
||||
- Authority docs (`docs/dev/31_AUTHORITY_PLUGIN_DEVELOPER_GUIDE.md`, upcoming `docs/11_AUTHORITY.md`).
|
||||
- Feedser quickstarts, CLI guides, Offline Kit manuals.
|
||||
- Release notes and migration playbooks.
|
||||
|
||||
## Operating Principles
|
||||
- Keep guides deterministic and in sync with shipped configuration samples.
|
||||
- Prefer tables/checklists for operator steps; flag security-sensitive actions.
|
||||
- Update `docs/TASKS.md` whenever work items change status (TODO/DOING/REVIEW/DONE/BLOCKED).
|
||||
|
||||
## Coordination
|
||||
- Authority Core & Plugin teams for auth-related changes.
|
||||
- Security Guild for threat-model outputs and mitigations.
|
||||
- DevEx for tooling diagrams and documentation pipeline.
|
||||
10
docs/TASKS.md
Normal file
10
docs/TASKS.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Docs Guild Task Board (UTC 2025-10-10)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| DOC4.AUTH-PDG | REVIEW | Docs Guild, Plugin Team | PLG6.DOC | Copy-edit `docs/dev/31_AUTHORITY_PLUGIN_DEVELOPER_GUIDE.md`, export lifecycle diagram, add LDAP RFC cross-link. | ✅ PR merged with polish; ✅ Diagram committed; ✅ Slack handoff posted. |
|
||||
| DOC1.AUTH | TODO | Docs Guild, Authority Core | CORE5B.DOC | Draft `docs/11_AUTHORITY.md` covering architecture, configuration, bootstrap flows. | ✅ Architecture + config sections approved by Core; ✅ Samples reference latest options; ✅ Offline note added. |
|
||||
| DOC3.Feedser-Authority | DOING (2025-10-10) | Docs Guild, DevEx | FSR4 | Polish operator/runbook sections (DOC3/DOC5) to document Feedser authority rollout, bypass logging, and enforcement checklist. | ✅ DOC3/DOC5 updated; ✅ enforcement deadline highlighted; ✅ Docs guild sign-off. |
|
||||
| DOC5.Feedser-Runbook | TODO | Docs Guild | DOC3.Feedser-Authority | Produce dedicated Feedser authority audit runbook covering log fields, monitoring recommendations, and troubleshooting steps. | ✅ Runbook published; ✅ linked from DOC3/DOC5; ✅ alerting guidance included. |
|
||||
|
||||
> Update statuses (TODO/DOING/REVIEW/DONE/BLOCKED) as progress changes. Keep guides in sync with configuration samples under `etc/`.
|
||||
@@ -38,6 +38,8 @@ Capability flags let the host reason about what your plug-in supports:
|
||||
|
||||
**Operational reminder:** the Authority host surfaces capability summaries during startup (see `AuthorityIdentityProviderRegistry` log lines). Use those logs during smoke tests to ensure manifests align with expectations.
|
||||
|
||||
**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.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -8,14 +10,14 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Telemetry;
|
||||
using StellaOps.Cli.Tests.Testing;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
using StellaOps.Cli.Commands;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Telemetry;
|
||||
using StellaOps.Cli.Tests.Testing;
|
||||
|
||||
namespace StellaOps.Cli.Tests.Commands;
|
||||
|
||||
public sealed class CommandHandlersTests
|
||||
{
|
||||
@@ -244,6 +246,80 @@ public sealed class CommandHandlersTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAuthWhoAmIAsync_ReturnsErrorWhenTokenMissing()
|
||||
{
|
||||
var original = Environment.ExitCode;
|
||||
using var tempDir = new TempDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
var options = new StellaOpsCliOptions
|
||||
{
|
||||
ResultsDirectory = Path.Combine(tempDir.Path, "results"),
|
||||
Authority = new StellaOpsCliAuthorityOptions
|
||||
{
|
||||
Url = "https://authority.example",
|
||||
ClientId = "cli",
|
||||
TokenCacheDirectory = tempDir.Path
|
||||
}
|
||||
};
|
||||
|
||||
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: new StubTokenClient());
|
||||
|
||||
await CommandHandlers.HandleAuthWhoAmIAsync(provider, options, verbose: false, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, Environment.ExitCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.ExitCode = original;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAuthWhoAmIAsync_ReportsClaimsForJwtToken()
|
||||
{
|
||||
var original = Environment.ExitCode;
|
||||
using var tempDir = new TempDirectory();
|
||||
|
||||
try
|
||||
{
|
||||
var options = new StellaOpsCliOptions
|
||||
{
|
||||
ResultsDirectory = Path.Combine(tempDir.Path, "results"),
|
||||
Authority = new StellaOpsCliAuthorityOptions
|
||||
{
|
||||
Url = "https://authority.example",
|
||||
ClientId = "cli",
|
||||
TokenCacheDirectory = tempDir.Path
|
||||
}
|
||||
};
|
||||
|
||||
var tokenClient = new StubTokenClient();
|
||||
tokenClient.CachedEntry = new StellaOpsTokenCacheEntry(
|
||||
CreateUnsignedJwt(
|
||||
("sub", "cli-user"),
|
||||
("aud", "feedser"),
|
||||
("iss", "https://authority.example"),
|
||||
("iat", 1_700_000_000),
|
||||
("nbf", 1_700_000_000)),
|
||||
"Bearer",
|
||||
DateTimeOffset.UtcNow.AddMinutes(30),
|
||||
new[] { StellaOpsScopes.FeedserJobsTrigger });
|
||||
|
||||
var provider = BuildServiceProvider(new StubBackendClient(new JobTriggerResult(true, "ok", null, null)), options: options, tokenClient: tokenClient);
|
||||
|
||||
await CommandHandlers.HandleAuthWhoAmIAsync(provider, options, verbose: true, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, Environment.ExitCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.ExitCode = original;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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,35 +255,51 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
private static Command BuildConfigCommand(StellaOpsCliOptions options)
|
||||
{
|
||||
var config = new Command("config", "Inspect CLI configuration state.");
|
||||
var show = new Command("show", "Display resolved configuration values.");
|
||||
|
||||
show.SetAction((_, _) =>
|
||||
{
|
||||
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}"
|
||||
};
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
Console.WriteLine(line);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
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}",
|
||||
$"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)
|
||||
{
|
||||
Console.WriteLine(line);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
config.Add(show);
|
||||
return config;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
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;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Spectre.Console;
|
||||
@@ -492,11 +494,322 @@ internal static class CommandHandlers
|
||||
logger.LogInformation("Scopes: {Scopes}", string.Join(", ", entry.Scopes));
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task TriggerJobAsync(
|
||||
IBackendOperationsClient client,
|
||||
ILogger logger,
|
||||
string jobKind,
|
||||
|
||||
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,
|
||||
string jobKind,
|
||||
IDictionary<string, object?> parameters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -522,6 +835,6 @@ internal static class CommandHandlers
|
||||
{
|
||||
logger.LogError("Job '{JobKind}' failed: {Message}", jobKind, result.Message);
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
@@ -127,26 +205,66 @@ public static class CliBootstrapper
|
||||
|
||||
return (bootstrap.Options, bootstrap.Configuration);
|
||||
}
|
||||
|
||||
|
||||
private static string ResolveWithFallback(string currentValue, IConfiguration configuration, params string[] keys)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(currentValue))
|
||||
{
|
||||
return currentValue;
|
||||
}
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
var value = configuration[key];
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
var value = configuration[key];
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
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,9 +1,11 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Cli.Configuration;
|
||||
|
||||
public sealed class StellaOpsCliOptions
|
||||
{
|
||||
|
||||
public sealed class StellaOpsCliOptions
|
||||
{
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
|
||||
public string BackendUrl { get; set; } = string.Empty;
|
||||
@@ -38,4 +40,17 @@ public sealed class StellaOpsCliAuthorityOptions
|
||||
public string Scope { get; set; } = StellaOpsScopes.FeedserJobsTrigger;
|
||||
|
||||
public string 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.
|
||||
@@ -7,7 +7,8 @@
|
||||
|Debian EVR comparer plus tests|BE-Merge (Distro WG)|Debian fixtures|DONE – DebianEvr comparer mirrors dpkg ordering with tilde/epoch handling and unit coverage.|
|
||||
|SemVer range resolver plus tests|BE-Merge (OSS WG)|OSV/GHSA fixtures|DONE – SemanticVersionRangeResolver covers introduced/fixed/lastAffected semantics with SemVer ordering tests.|
|
||||
|Canonical hash and merge_event writer|BE-Merge|Models, Storage.Mongo|DONE – Hash calculator + MergeEventWriter compute canonical SHA-256 digests and persist merge events.|
|
||||
|Conflict detection and metrics|BE-Merge|Core|**DONE** – merge meters emit override/conflict counters and structured audits (`AdvisoryPrecedenceMerger`).|
|
||||
|End-to-end determinism test|QA|Merge, key connectors|**DONE** – `MergePrecedenceIntegrationTests.MergePipeline_IsDeterministicAcrossRuns` guards determinism.|
|
||||
|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.|
|
||||
|Conflict detection and metrics|BE-Merge|Core|**DONE** – merge meters emit override/conflict counters and structured audits (`AdvisoryPrecedenceMerger`).|
|
||||
|End-to-end determinism test|QA|Merge, key connectors|**DONE** – `MergePrecedenceIntegrationTests.MergePipeline_IsDeterministicAcrossRuns` guards determinism.|
|
||||
|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.|
|
||||
|
||||
@@ -11,5 +11,6 @@
|
||||
|Golden mapping fixtures|QA|Fixtures|**DONE** – fixture validation test now snapshots RHSA-2025:0001/0002/0003 with env-driven regeneration.|
|
||||
|Job scheduling defaults for source:redhat tasks|BE-Core|JobScheduler|**DONE** – Cron windows + per-job timeouts defined for fetch/parse/map.|
|
||||
|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.|
|
||||
|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);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
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,43 +328,62 @@ 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;
|
||||
_previousDsn = Environment.GetEnvironmentVariable("FEEDSER_STORAGE__DSN");
|
||||
_previousDriver = Environment.GetEnvironmentVariable("FEEDSER_STORAGE__DRIVER");
|
||||
_previousTimeout = Environment.GetEnvironmentVariable("FEEDSER_STORAGE__COMMANDTIMEOUTSECONDS");
|
||||
_previousTelemetryEnabled = Environment.GetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLED");
|
||||
_previousTelemetryLogging = Environment.GetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLELOGGING");
|
||||
_previousTelemetryTracing = Environment.GetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLETRACING");
|
||||
_previousTelemetryMetrics = Environment.GetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLEMETRICS");
|
||||
Environment.SetEnvironmentVariable("FEEDSER_STORAGE__DSN", connectionString);
|
||||
Environment.SetEnvironmentVariable("FEEDSER_STORAGE__DRIVER", "mongo");
|
||||
Environment.SetEnvironmentVariable("FEEDSER_STORAGE__COMMANDTIMEOUTSECONDS", "30");
|
||||
Environment.SetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLED", "false");
|
||||
Environment.SetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLELOGGING", "false");
|
||||
Environment.SetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLETRACING", "false");
|
||||
Environment.SetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLEMETRICS", "false");
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.ConfigureAppConfiguration((context, configurationBuilder) =>
|
||||
{
|
||||
var settings = new Dictionary<string, string?>
|
||||
{
|
||||
["Plugins:Directory"] = Path.Combine(context.HostingEnvironment.ContentRootPath, "PluginBinaries"),
|
||||
};
|
||||
|
||||
configurationBuilder.AddInMemoryCollection(settings!);
|
||||
});
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.AddSingleton<StubJobCoordinator>();
|
||||
services.AddSingleton<IJobCoordinator>(sp => sp.GetRequiredService<StubJobCoordinator>());
|
||||
_previousTimeout = Environment.GetEnvironmentVariable("FEEDSER_STORAGE__COMMANDTIMEOUTSECONDS");
|
||||
_previousTelemetryEnabled = Environment.GetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLED");
|
||||
_previousTelemetryLogging = Environment.GetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLELOGGING");
|
||||
_previousTelemetryTracing = Environment.GetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLETRACING");
|
||||
_previousTelemetryMetrics = Environment.GetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLEMETRICS");
|
||||
Environment.SetEnvironmentVariable("FEEDSER_STORAGE__DSN", connectionString);
|
||||
Environment.SetEnvironmentVariable("FEEDSER_STORAGE__DRIVER", "mongo");
|
||||
Environment.SetEnvironmentVariable("FEEDSER_STORAGE__COMMANDTIMEOUTSECONDS", "30");
|
||||
Environment.SetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLED", "false");
|
||||
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)
|
||||
{
|
||||
builder.ConfigureAppConfiguration((context, configurationBuilder) =>
|
||||
{
|
||||
var settings = new Dictionary<string, string?>
|
||||
{
|
||||
["Plugins:Directory"] = Path.Combine(context.HostingEnvironment.ContentRootPath, "PluginBinaries"),
|
||||
};
|
||||
|
||||
configurationBuilder.AddInMemoryCollection(settings!);
|
||||
});
|
||||
|
||||
builder.ConfigureLogging(logging =>
|
||||
{
|
||||
logging.AddProvider(LoggerProvider);
|
||||
});
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.AddSingleton<StubJobCoordinator>();
|
||||
services.AddSingleton<IJobCoordinator>(sp => sp.GetRequiredService<StubJobCoordinator>());
|
||||
services.PostConfigure<FeedserOptions>(options =>
|
||||
{
|
||||
options.Storage.Driver = "mongo";
|
||||
@@ -299,20 +398,176 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
_authorityConfigure?.Invoke(options.Authority);
|
||||
});
|
||||
});
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.AddSingleton<IStartupFilter, RemoteIpStartupFilter>();
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
Environment.SetEnvironmentVariable("FEEDSER_STORAGE__DSN", _previousDsn);
|
||||
Environment.SetEnvironmentVariable("FEEDSER_STORAGE__DRIVER", _previousDriver);
|
||||
Environment.SetEnvironmentVariable("FEEDSER_STORAGE__COMMANDTIMEOUTSECONDS", _previousTimeout);
|
||||
Environment.SetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLED", _previousTelemetryEnabled);
|
||||
Environment.SetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLELOGGING", _previousTelemetryLogging);
|
||||
Environment.SetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLETRACING", _previousTelemetryTracing);
|
||||
Environment.SetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLEMETRICS", _previousTelemetryMetrics);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
Environment.SetEnvironmentVariable("FEEDSER_STORAGE__DSN", _previousDsn);
|
||||
Environment.SetEnvironmentVariable("FEEDSER_STORAGE__DRIVER", _previousDriver);
|
||||
Environment.SetEnvironmentVariable("FEEDSER_STORAGE__COMMANDTIMEOUTSECONDS", _previousTimeout);
|
||||
Environment.SetEnvironmentVariable("FEEDSER_TELEMETRY__ENABLED", _previousTelemetryEnabled);
|
||||
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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record HealthPayload(string Status, DateTimeOffset StartedAt, double UptimeSeconds, StoragePayload Storage, TelemetryPayload Telemetry);
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Feedser.WebService.Options;
|
||||
|
||||
namespace StellaOps.Feedser.WebService.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Emits structured audit logs for job endpoint authorization decisions, including bypass usage.
|
||||
/// </summary>
|
||||
public sealed class JobAuthorizationAuditFilter : IEndpointFilter
|
||||
{
|
||||
internal const string LoggerName = "Feedser.Authorization.Audit";
|
||||
|
||||
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(next);
|
||||
|
||||
var httpContext = context.HttpContext;
|
||||
var options = httpContext.RequestServices.GetRequiredService<IOptions<FeedserOptions>>().Value;
|
||||
var authority = options.Authority;
|
||||
|
||||
if (authority is null || !authority.Enabled)
|
||||
{
|
||||
return await next(context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var logger = httpContext.RequestServices
|
||||
.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger(LoggerName);
|
||||
|
||||
var remoteAddress = httpContext.Connection.RemoteIpAddress;
|
||||
var matcher = new NetworkMaskMatcher(authority.BypassNetworks);
|
||||
var user = httpContext.User;
|
||||
var isAuthenticated = user?.Identity?.IsAuthenticated ?? false;
|
||||
var bypassUsed = !isAuthenticated && matcher.IsAllowed(remoteAddress);
|
||||
|
||||
var result = await next(context).ConfigureAwait(false);
|
||||
|
||||
var scopes = ExtractScopes(user);
|
||||
var subject = user?.FindFirst(StellaOpsClaimTypes.Subject)?.Value;
|
||||
var clientId = user?.FindFirst(StellaOpsClaimTypes.ClientId)?.Value;
|
||||
|
||||
logger.LogInformation(
|
||||
"Feedser authorization audit route={Route} status={StatusCode} subject={Subject} clientId={ClientId} scopes={Scopes} bypass={Bypass} remote={RemoteAddress}",
|
||||
httpContext.Request.Path.Value ?? string.Empty,
|
||||
httpContext.Response.StatusCode,
|
||||
string.IsNullOrWhiteSpace(subject) ? "(anonymous)" : subject,
|
||||
string.IsNullOrWhiteSpace(clientId) ? "(none)" : clientId,
|
||||
scopes.Length == 0 ? "(none)" : string.Join(',', scopes),
|
||||
bypassUsed,
|
||||
remoteAddress?.ToString() ?? IPAddress.None.ToString());
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string[] ExtractScopes(ClaimsPrincipal? principal)
|
||||
{
|
||||
if (principal is null)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var values = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.ScopeItem))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(claim.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
values.Add(claim.Value);
|
||||
}
|
||||
|
||||
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.Scope))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(claim.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var parts = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var normalized = StellaOpsScopes.Normalize(part);
|
||||
if (!string.IsNullOrEmpty(normalized))
|
||||
{
|
||||
values.Add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return values.Count == 0 ? Array.Empty<string>() : values.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,8 @@ public sealed class FeedserOptions
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
public bool 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 _))
|
||||
|
||||
@@ -17,11 +17,12 @@ using StellaOps.Feedser.Core.Jobs;
|
||||
using StellaOps.Feedser.Storage.Mongo;
|
||||
using StellaOps.Feedser.WebService.Diagnostics;
|
||||
using Serilog;
|
||||
using StellaOps.Feedser.Merge;
|
||||
using StellaOps.Feedser.Merge;
|
||||
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,13 +44,23 @@ builder.Configuration.AddStellaOpsDefaults(options =>
|
||||
};
|
||||
});
|
||||
|
||||
var feedserOptions = builder.Configuration.BindOptions<FeedserOptions>(postConfigure: (opts, _) => FeedserOptionsValidator.Validate(opts));
|
||||
builder.Services.AddOptions<FeedserOptions>()
|
||||
.Bind(builder.Configuration)
|
||||
.PostConfigure(FeedserOptionsValidator.Validate)
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.ConfigureFeedserTelemetry(feedserOptions);
|
||||
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(options =>
|
||||
{
|
||||
FeedserOptionsPostConfigure.Apply(options, contentRootPath);
|
||||
FeedserOptionsValidator.Validate(options);
|
||||
})
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.ConfigureFeedserTelemetry(feedserOptions);
|
||||
|
||||
builder.Services.AddMongoStorage(storageOptions =>
|
||||
{
|
||||
@@ -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,
|
||||
@@ -109,9 +120,20 @@ var pluginHostOptions = BuildPluginOptions(feedserOptions, builder.Environment.C
|
||||
builder.Services.RegisterPluginRoutines(builder.Configuration, pluginHostOptions);
|
||||
|
||||
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,12 +421,12 @@ var jobsListEndpoint = app.MapGet("/jobs", async (string? kind, int? limit, IJob
|
||||
var runs = await coordinator.GetRecentRunsAsync(kind, take, cancellationToken).ConfigureAwait(false);
|
||||
var payload = runs.Select(JobRunResponse.FromSnapshot).ToArray();
|
||||
return JsonResult(payload);
|
||||
});
|
||||
if (authorityEnabled)
|
||||
}).AddEndpointFilter<JobAuthorizationAuditFilter>();
|
||||
if (enforceAuthority)
|
||||
{
|
||||
jobsListEndpoint.RequireAuthorization(JobsPolicyName);
|
||||
}
|
||||
|
||||
|
||||
var jobByIdEndpoint = app.MapGet("/jobs/{runId:guid}", async (Guid runId, IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
ApplyNoCache(context.Response);
|
||||
@@ -377,12 +438,12 @@ 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);
|
||||
}
|
||||
|
||||
|
||||
var jobDefinitionsEndpoint = app.MapGet("/jobs/definitions", async (IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
ApplyNoCache(context.Response);
|
||||
@@ -402,14 +463,14 @@ var jobDefinitionsEndpoint = app.MapGet("/jobs/definitions", async (IJobCoordina
|
||||
lastRuns.TryGetValue(definition.Kind, out var lastRun);
|
||||
responses.Add(JobDefinitionResponse.FromDefinition(definition, lastRun));
|
||||
}
|
||||
|
||||
|
||||
return JsonResult(responses);
|
||||
});
|
||||
if (authorityEnabled)
|
||||
}).AddEndpointFilter<JobAuthorizationAuditFilter>();
|
||||
if (enforceAuthority)
|
||||
{
|
||||
jobDefinitionsEndpoint.RequireAuthorization(JobsPolicyName);
|
||||
}
|
||||
|
||||
|
||||
var jobDefinitionEndpoint = app.MapGet("/jobs/definitions/{kind}", async (string kind, IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
ApplyNoCache(context.Response);
|
||||
@@ -424,15 +485,15 @@ var jobDefinitionEndpoint = app.MapGet("/jobs/definitions/{kind}", async (string
|
||||
|
||||
var lastRuns = await coordinator.GetLastRunsAsync(new[] { definition.Kind }, cancellationToken).ConfigureAwait(false);
|
||||
lastRuns.TryGetValue(definition.Kind, out var lastRun);
|
||||
|
||||
|
||||
var response = JobDefinitionResponse.FromDefinition(definition, lastRun);
|
||||
return JsonResult(response);
|
||||
});
|
||||
if (authorityEnabled)
|
||||
}).AddEndpointFilter<JobAuthorizationAuditFilter>();
|
||||
if (enforceAuthority)
|
||||
{
|
||||
jobDefinitionEndpoint.RequireAuthorization(JobsPolicyName);
|
||||
}
|
||||
|
||||
|
||||
var jobDefinitionRunsEndpoint = app.MapGet("/jobs/definitions/{kind}/runs", async (string kind, int? limit, IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
ApplyNoCache(context.Response);
|
||||
@@ -445,29 +506,29 @@ var jobDefinitionRunsEndpoint = app.MapGet("/jobs/definitions/{kind}/runs", asyn
|
||||
return Problem(context, "Job definition not found", StatusCodes.Status404NotFound, ProblemTypes.NotFound, $"Job kind '{kind}' is not registered.");
|
||||
}
|
||||
|
||||
var take = Math.Clamp(limit.GetValueOrDefault(20), 1, 200);
|
||||
var runs = await coordinator.GetRecentRunsAsync(kind, take, cancellationToken).ConfigureAwait(false);
|
||||
var take = Math.Clamp(limit.GetValueOrDefault(20), 1, 200);
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
var activeJobsEndpoint = app.MapGet("/jobs/active", async (IJobCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
ApplyNoCache(context.Response);
|
||||
|
||||
var runs = await coordinator.GetActiveRunsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var runs = await coordinator.GetActiveRunsAsync(cancellationToken).ConfigureAwait(false);
|
||||
var payload = runs.Select(JobRunResponse.FromSnapshot).ToArray();
|
||||
return JsonResult(payload);
|
||||
});
|
||||
if (authorityEnabled)
|
||||
}).AddEndpointFilter<JobAuthorizationAuditFilter>();
|
||||
if (enforceAuthority)
|
||||
{
|
||||
activeJobsEndpoint.RequireAuthorization(JobsPolicyName);
|
||||
}
|
||||
|
||||
|
||||
var triggerJobEndpoint = app.MapPost("/jobs/{*jobKind}", async (string jobKind, JobTriggerRequest request, IJobCoordinator coordinator, HttpContext context) =>
|
||||
{
|
||||
ApplyNoCache(context.Response);
|
||||
@@ -542,13 +603,13 @@ var triggerJobEndpoint = app.MapPost("/jobs/{*jobKind}", async (string jobKind,
|
||||
|
||||
return Problem(context, "Job execution failed", StatusCodes.Status500InternalServerError, ProblemTypes.JobFailure, result.ErrorMessage, extensions);
|
||||
}
|
||||
|
||||
default:
|
||||
JobMetrics.TriggerFailureCounter.Add(1, tags);
|
||||
return Problem(context, "Unexpected job outcome", StatusCodes.Status500InternalServerError, ProblemTypes.JobFailure, $"Job '{jobKind}' returned outcome '{outcome}'.");
|
||||
}
|
||||
});
|
||||
if (authorityEnabled)
|
||||
|
||||
default:
|
||||
JobMetrics.TriggerFailureCounter.Add(1, tags);
|
||||
return Problem(context, "Unexpected job outcome", StatusCodes.Status500InternalServerError, ProblemTypes.JobFailure, $"Job '{jobKind}' returned outcome '{outcome}'.");
|
||||
}
|
||||
}).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. |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.100-preview.7.25380.108",
|
||||
"rollForward": "latestMinor"
|
||||
}
|
||||
}
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.100-preview.7.25380.108",
|
||||
"rollForward": "latestMinor"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user