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

This commit is contained in:
Vladimir Moushkov
2025-10-10 18:33:10 +03:00
parent df5984d07e
commit 3083c77a9e
93 changed files with 5053 additions and 2101 deletions

View File

@@ -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 FND1FND3 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 PLG1PLG3 (abstractions, plugin loader integration, Mongo-based Standard plugin stub). Coordinate schema details with Team 1.”
- Do not start Phase 2 until Team 2 finishes CORE1CORE2 and Team 3 finishes PLG1PLG3.
Phase 2 Core Expansion & Libraries
- Prompt Team 2: “Continue with StellaOps.Authority.TODOS.AuthorityCore.md tasks CORE3CORE6 (Mongo stores, plugin capability wiring, bootstrap admin APIs).”
- Prompt Team 3: “Advance PLG4PLG6 (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 LIB1LIB4 (abstractions, NetworkMaskMatcher, ServerIntegration DI, Auth.Client).”
- Move to Phase 3 only after CORE3CORE6, PLG4PLG6, and LIB1LIB4 are DONE.
Phase 3 Integration & Ops
- Prompt Team 2: “Finish CORE7CORE10 (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 LIB5LIB6 (Polly integration, packaging metadata).”
- Prompt Team 5: “Read AGENTS.md, StellaOps.Authority.TODOS.md, and StellaOps.Authority.TODOS.Feedser.md. Execute FSR1FSR3 (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 CLI1CLI4 (config, auth commands, bearer injection, docs).”
- Prompt Team 7: “Read AGENTS.md, StellaOps.Authority.TODOS.md, and StellaOps.Authority.TODOS.DevOps.md. Execute OPS1OPS5 (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 DOC1DOC4 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 SEC1SEC5 (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.

View File

@@ -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. | LIB1LIB5 | 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 CLI1CLI3).
- 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.

View File

@@ -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 FND1FND5 | 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.

View File

@@ -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. | CLI1CLI3 | 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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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). | FSR1FSR3 | Includes rollback plan + FAQ. |
| 4 | DOC4 | Create plugin developer how-to (leveraging Plugin Team notes) covering packaging, capability flags, logging. | PLG1PLG6 | **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. | LIB1LIB5 | 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.

View File

@@ -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. | FSR1FSR3 | Coordinate with Docs team for final wording. |
| 5 | FSR5 | Build integration test harness (Authority + Feedser docker-compose) verifying token issuance and job triggering. | CORE1CORE5, 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.

View File

@@ -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). | PLG1PLG5 | **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). | PLG1PLG4 | **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.

View File

@@ -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. | CORE5CORE7 | 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. | CORE1CORE10 | 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 doesnt 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.

View File

@@ -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.

View File

@@ -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 PLG1PLG3|BE-Auth Plugin|Authority DevEx|**DONE** abstractions/tests shipped, plugin loader integrated, and Mongo-backed Standard plugin stub operational with bootstrap seeding.|
|Authority plugin PLG4PLG6|BE-Auth Plugin, DevEx/Docs|Authority plugin PLG1PLG3|**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`.|

View File

@@ -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 20251009, 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.

View File

@@ -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`.

View File

@@ -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 airgapped 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**

View File

@@ -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.

View File

@@ -140,10 +140,25 @@ cosign verify ghcr.io/stellaops/backend@sha256:<DIGEST> \
| Control | Implementation |
| ------------ | ----------------------------------------------------------------- |
| Log format | Serilog JSON; ship via FluentBit 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 48h, P95 walltime>5s, Redis used memory>75% |
| Log format | Serilog JSON; ship via FluentBit 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 48h, P95 walltime>5s, Redis used memory>75% |
### 7.1Feedser 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.
## 8Update & patch strategy

View File

@@ -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 ~50MB merged vuln DB)
docker compose --env-file .env -f docker-compose.stella-ops.yml up -d
````
*Default login:* `admin / changeme`
UI: [https://\&lt;host\&gt;:8443](https://&lt;host&gt;:8443) (selfsigned certificate)
> **Pinning bestpractice** 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 ~50MB merged vuln DB)
docker compose --env-file .env -f docker-compose.stella-ops.yml up -d
````
*Default login:* `admin / changeme`
UI: [https://\&lt;host\&gt;:8443](https://&lt;host&gt;:8443) (selfsigned certificate)
> **Pinning bestpractice** 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
View 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
View 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/`.

View File

@@ -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:

View 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 deployments 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.

View 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 24h 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 36).
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 youre 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).

View 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": []
}

View 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 10min. |
| `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 5min; confirm trusted clients arent 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.

View File

@@ -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"

View File

@@ -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
View 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).

View File

@@ -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
View 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. |

View 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

View File

@@ -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.

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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)!;
}
}

View File

@@ -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.

View File

@@ -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);
}
});
}
}

View File

@@ -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>

View File

@@ -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();
}
}

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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.

View File

@@ -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">

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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.

View File

@@ -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);
}
}

View File

@@ -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 =>
{

View File

@@ -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 | PLG1PLG5 | 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 | PLG1PLG3 | 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.

View File

@@ -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
{

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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));
}
}

View File

@@ -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();

View 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.

View File

@@ -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('/', '_');
}
}

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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;
}
}
}
}
}
}

View File

@@ -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);

View File

@@ -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; }
}

View File

@@ -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;

View File

@@ -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.|

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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>

View 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);
}
}

View File

@@ -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>

View 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.

View 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);
}

View 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);
}

View 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>

View 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 &lt; 250ms. |
| 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. | CORE5CORE7 | ✅ 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 SEC1SEC4 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 ≈ 19MiB, 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.

View File

@@ -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.|

View File

@@ -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.|

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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();
}
}

View File

@@ -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>();
}
}

View File

@@ -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;
}
}
}

View File

@@ -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 _))

View File

@@ -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);
}

View File

@@ -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.|

View 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.

View 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

View File

@@ -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"
}
}