feat: Implement console session management with tenant and profile handling

- Add ConsoleSessionStore for managing console session state including tenants, profile, and token information.
- Create OperatorContextService to manage operator context for orchestrator actions.
- Implement OperatorMetadataInterceptor to enrich HTTP requests with operator context metadata.
- Develop ConsoleProfileComponent to display user profile and session details, including tenant information and access tokens.
- Add corresponding HTML and SCSS for ConsoleProfileComponent to enhance UI presentation.
- Write unit tests for ConsoleProfileComponent to ensure correct rendering and functionality.
This commit is contained in:
2025-10-28 09:58:55 +02:00
parent 4d932cc1ba
commit 4e3e575db5
501 changed files with 51904 additions and 6663 deletions

View File

@@ -0,0 +1,689 @@
openapi: 3.1.0
info:
title: StellaOps Authority Authentication API
summary: Token issuance, introspection, revocation, and key discovery endpoints exposed by the Authority service.
description: |
The Authority service issues OAuth 2.1 access tokens for StellaOps components, enforcing tenant and scope
restrictions configured per client. This specification describes the authentication surface only; domain APIs
are documented by their owning services.
version: 0.1.0
jsonSchemaDialect: https://json-schema.org/draft/2020-12/schema
servers:
- url: https://authority.stellaops.local
description: Example Authority deployment
tags:
- name: Authentication
description: OAuth 2.1 token exchange, introspection, and revocation flows.
- name: Keys
description: JSON Web Key Set discovery.
components:
securitySchemes:
ClientSecretBasic:
type: http
scheme: basic
description: HTTP Basic authentication with `client_id` and `client_secret`.
OAuthPassword:
type: oauth2
description: Resource owner password exchange for Authority-managed identities.
flows:
password:
tokenUrl: /token
refreshUrl: /token
scopes:
advisory:ingest: Submit advisory ingestion payloads.
advisory:read: Read advisory ingestion data.
aoc:verify: Execute Aggregation-Only Contract verification workflows.
authority.audit.read: Read Authority audit logs.
authority.clients.manage: Manage Authority client registrations.
authority.users.manage: Manage Authority users.
authority:tenants.read: Read the Authority tenant catalog.
concelier.jobs.trigger: Trigger Concelier aggregation jobs.
concelier.merge: Manage Concelier merge operations.
effective:write: Write effective findings (Policy Engine service identity only).
email: Access email claim data.
exceptions:approve: Approve exception workflows.
findings:read: Read effective findings emitted by Policy Engine.
graph:export: Export graph artefacts.
graph:read: Read graph explorer data.
graph:simulate: Run graph what-if simulations.
graph:write: Enqueue or mutate graph build jobs.
offline_access: Request refresh tokens for offline access.
openid: Request OpenID Connect identity tokens.
orch:operate: Execute privileged Orchestrator control actions.
orch:read: Read Orchestrator job state.
policy:author: Author Policy Studio drafts and workspaces.
policy:activate: Activate policy revisions.
policy:approve: Approve or reject policy drafts.
policy:audit: Inspect Policy Studio audit history.
policy:edit: Edit policy definitions.
policy:operate: Operate Policy Studio promotions and runs.
policy:read: Read policy definitions and metadata.
policy:run: Trigger policy executions.
policy:submit: Submit policy drafts for review.
policy:review: Review Policy Studio drafts and leave feedback.
policy:simulate: Execute Policy Studio simulations.
policy:write: Create or update policy drafts.
profile: Access profile claim data.
signals:admin: Administer Signals ingestion and routing settings.
signals:read: Read Signals events and state.
signals:write: Publish Signals events or mutate state.
stellaops.bypass: Bypass trust boundary protections (restricted identities only).
ui.read: Read Console UX resources.
vex:ingest: Submit VEX ingestion payloads.
vex:read: Read VEX ingestion data.
vuln:read: Read vulnerability permalinks and overlays.
authorizationCode:
authorizationUrl: /authorize
tokenUrl: /token
refreshUrl: /token
scopes:
advisory:ingest: Submit advisory ingestion payloads.
advisory:read: Read advisory ingestion data.
aoc:verify: Execute Aggregation-Only Contract verification workflows.
authority.audit.read: Read Authority audit logs.
authority.clients.manage: Manage Authority client registrations.
authority.users.manage: Manage Authority users.
authority:tenants.read: Read the Authority tenant catalog.
concelier.jobs.trigger: Trigger Concelier aggregation jobs.
concelier.merge: Manage Concelier merge operations.
effective:write: Write effective findings (Policy Engine service identity only).
email: Access email claim data.
exceptions:approve: Approve exception workflows.
findings:read: Read effective findings emitted by Policy Engine.
graph:export: Export graph artefacts.
graph:read: Read graph explorer data.
graph:simulate: Run graph what-if simulations.
graph:write: Enqueue or mutate graph build jobs.
offline_access: Request refresh tokens for offline access.
openid: Request OpenID Connect identity tokens.
orch:operate: Execute privileged Orchestrator control actions.
orch:read: Read Orchestrator job state.
policy:author: Author Policy Studio drafts and workspaces.
policy:activate: Activate policy revisions.
policy:approve: Approve or reject policy drafts.
policy:audit: Inspect Policy Studio audit history.
policy:edit: Edit policy definitions.
policy:operate: Operate Policy Studio promotions and runs.
policy:read: Read policy definitions and metadata.
policy:run: Trigger policy executions.
policy:submit: Submit policy drafts for review.
policy:review: Review Policy Studio drafts and leave feedback.
policy:simulate: Execute Policy Studio simulations.
policy:write: Create or update policy drafts.
profile: Access profile claim data.
signals:admin: Administer Signals ingestion and routing settings.
signals:read: Read Signals events and state.
signals:write: Publish Signals events or mutate state.
stellaops.bypass: Bypass trust boundary protections (restricted identities only).
ui.read: Read Console UX resources.
vex:ingest: Submit VEX ingestion payloads.
vex:read: Read VEX ingestion data.
vuln:read: Read vulnerability permalinks and overlays.
OAuthClientCredentials:
type: oauth2
description: Client credential exchange for machine-to-machine identities.
flows:
clientCredentials:
tokenUrl: /token
scopes:
advisory:ingest: Submit advisory ingestion payloads.
advisory:read: Read advisory ingestion data.
aoc:verify: Execute Aggregation-Only Contract verification workflows.
authority.audit.read: Read Authority audit logs.
authority.clients.manage: Manage Authority client registrations.
authority.users.manage: Manage Authority users.
authority:tenants.read: Read the Authority tenant catalog.
concelier.jobs.trigger: Trigger Concelier aggregation jobs.
concelier.merge: Manage Concelier merge operations.
effective:write: Write effective findings (Policy Engine service identity only).
email: Access email claim data.
exceptions:approve: Approve exception workflows.
findings:read: Read effective findings emitted by Policy Engine.
graph:export: Export graph artefacts.
graph:read: Read graph explorer data.
graph:simulate: Run graph what-if simulations.
graph:write: Enqueue or mutate graph build jobs.
offline_access: Request refresh tokens for offline access.
openid: Request OpenID Connect identity tokens.
orch:operate: Execute privileged Orchestrator control actions.
orch:read: Read Orchestrator job state.
policy:author: Author Policy Studio drafts and workspaces.
policy:activate: Activate policy revisions.
policy:approve: Approve or reject policy drafts.
policy:audit: Inspect Policy Studio audit history.
policy:edit: Edit policy definitions.
policy:operate: Operate Policy Studio promotions and runs.
policy:read: Read policy definitions and metadata.
policy:run: Trigger policy executions.
policy:submit: Submit policy drafts for review.
policy:review: Review Policy Studio drafts and leave feedback.
policy:simulate: Execute Policy Studio simulations.
policy:write: Create or update policy drafts.
profile: Access profile claim data.
signals:admin: Administer Signals ingestion and routing settings.
signals:read: Read Signals events and state.
signals:write: Publish Signals events or mutate state.
stellaops.bypass: Bypass trust boundary protections (restricted identities only).
ui.read: Read Console UX resources.
vex:ingest: Submit VEX ingestion payloads.
vex:read: Read VEX ingestion data.
vuln:read: Read vulnerability permalinks and overlays.
schemas:
TokenResponse:
type: object
description: OAuth 2.1 bearer token response.
properties:
access_token:
type: string
description: Access token encoded as JWT.
token_type:
type: string
description: Token type indicator. Always `Bearer`.
expires_in:
type: integer
description: Lifetime of the access token, in seconds.
minimum: 1
refresh_token:
type: string
description: Refresh token issued when the grant allows offline access.
scope:
type: string
description: Space-delimited scopes granted in the response.
id_token:
type: string
description: ID token issued for authorization-code flows.
required:
- access_token
- token_type
- expires_in
OAuthErrorResponse:
type: object
description: RFC 6749 compliant error envelope.
properties:
error:
type: string
description: Machine-readable error code.
error_description:
type: string
description: Human-readable error description.
error_uri:
type: string
format: uri
description: Link to documentation about the error.
required:
- error
PasswordGrantRequest:
type: object
required:
- grant_type
- client_id
- username
- password
properties:
grant_type:
type: string
const: password
client_id:
type: string
description: Registered client identifier. May also be supplied via HTTP Basic auth.
client_secret:
type: string
description: Client secret. Required for confidential clients when not using HTTP Basic auth.
scope:
type: string
description: Space-delimited scopes being requested.
username:
type: string
description: Resource owner username.
password:
type: string
description: Resource owner password.
authority_provider:
type: string
description: Optional identity provider hint. Required when multiple password-capable providers are registered.
description: Form-encoded payload for password grant exchange.
ClientCredentialsGrantRequest:
type: object
required:
- grant_type
- client_id
properties:
grant_type:
type: string
const: client_credentials
client_id:
type: string
description: Registered client identifier. May also be supplied via HTTP Basic auth.
client_secret:
type: string
description: Client secret. Required for confidential clients when not using HTTP Basic auth.
scope:
type: string
description: Space-delimited scopes being requested.
authority_provider:
type: string
description: Optional identity provider hint for plugin-backed clients.
operator_reason:
type: string
description: Required when requesting `orch:operate`; explains the operator action.
maxLength: 256
operator_ticket:
type: string
description: Required when requesting `orch:operate`; tracks the external change ticket or incident.
maxLength: 128
description: Form-encoded payload for client credentials exchange.
RefreshTokenGrantRequest:
type: object
required:
- grant_type
- refresh_token
properties:
grant_type:
type: string
const: refresh_token
client_id:
type: string
description: Registered client identifier. May also be supplied via HTTP Basic auth.
client_secret:
type: string
description: Client secret. Required for confidential clients when not using HTTP Basic auth.
refresh_token:
type: string
description: Previously issued refresh token.
scope:
type: string
description: Optional scope list to narrow the requested access.
description: Form-encoded payload for refresh token exchange.
RevocationRequest:
type: object
required:
- token
properties:
token:
type: string
description: Token value or token identifier to revoke.
token_type_hint:
type: string
description: Optional token type hint (`access_token` or `refresh_token`).
description: Form-encoded payload for token revocation.
IntrospectionRequest:
type: object
required:
- token
properties:
token:
type: string
description: Token value whose state should be introspected.
token_type_hint:
type: string
description: Optional token type hint (`access_token` or `refresh_token`).
description: Form-encoded payload for token introspection.
IntrospectionResponse:
type: object
description: Active token descriptor compliant with RFC 7662.
properties:
active:
type: boolean
description: Indicates whether the token is currently active.
scope:
type: string
description: Space-delimited list of scopes granted to the token.
client_id:
type: string
description: Client identifier associated with the token.
sub:
type: string
description: Subject identifier when the token represents an end-user.
username:
type: string
description: Preferred username associated with the subject.
token_type:
type: string
description: Type of the token (e.g., `Bearer`).
exp:
type: integer
description: Expiration timestamp (seconds since UNIX epoch).
iat:
type: integer
description: Issued-at timestamp (seconds since UNIX epoch).
nbf:
type: integer
description: Not-before timestamp (seconds since UNIX epoch).
aud:
type: array
description: Audience values associated with the token.
items:
type: string
iss:
type: string
description: Issuer identifier.
jti:
type: string
description: JWT identifier corresponding to the token.
tenant:
type: string
description: Tenant associated with the token, when assigned.
confirmation:
type: object
description: Sender-constrained confirmation data (e.g., mTLS thumbprint, DPoP JWK thumbprint).
required:
- active
JwksDocument:
type: object
description: JSON Web Key Set published by the Authority.
properties:
keys:
type: array
items:
$ref: '#/components/schemas/Jwk'
required:
- keys
Jwk:
type: object
description: Public key material for token signature validation.
properties:
kid:
type: string
description: Key identifier.
kty:
type: string
description: Key type (e.g., `EC`, `RSA`).
use:
type: string
description: Intended key use (`sig`).
alg:
type: string
description: Signing algorithm (e.g., `ES384`).
crv:
type: string
description: Elliptic curve identifier when applicable.
x:
type: string
description: X coordinate for EC keys.
y:
type: string
description: Y coordinate for EC keys.
status:
type: string
description: Operational status metadata for the key (e.g., `active`, `retiring`).
paths:
/token:
post:
tags:
- Authentication
summary: Exchange credentials for tokens
description: |
Issues OAuth 2.1 bearer tokens for StellaOps clients. Supports password, client credentials,
authorization-code, device, and refresh token grants. Confidential clients must authenticate using
HTTP Basic auth or `client_secret` form fields.
security:
- ClientSecretBasic: []
- {}
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
oneOf:
- $ref: '#/components/schemas/PasswordGrantRequest'
- $ref: '#/components/schemas/ClientCredentialsGrantRequest'
- $ref: '#/components/schemas/RefreshTokenGrantRequest'
encoding:
authority_provider:
style: form
explode: false
examples:
passwordGrant:
summary: Password grant for tenant-scoped ingestion bot
value:
grant_type: password
client_id: ingest-cli
client_secret: s3cr3t
username: ingest-bot
password: pa55w0rd!
scope: advisory:ingest vex:ingest
authority_provider: primary-directory
authorizationCode:
summary: Authorization code exchange for Console UI session
value:
grant_type: authorization_code
client_id: console-ui
code: 2Lba1WtwPLfZ2b0Z9uPrsQ
redirect_uri: https://console.stellaops.local/auth/callback
code_verifier: g3ZnL91QJ6i4zO_86oI4CDnZ7gS0bSeK
clientCredentials:
summary: Client credentials exchange for Policy Engine
value:
grant_type: client_credentials
client_id: policy-engine
client_secret: 9c39f602-2f2b-4f29
scope: effective:write findings:read
operator_reason: Deploying policy change 1234
operator_ticket: CHG-004211
refreshToken:
summary: Refresh token rotation for console session
value:
grant_type: refresh_token
client_id: console-ui
refresh_token: 0.rg9pVlsGzXE8Q
responses:
'200':
description: Token exchange succeeded.
content:
application/json:
schema:
$ref: '#/components/schemas/TokenResponse'
examples:
passwordGrant:
summary: Password grant success response
value:
access_token: eyJhbGciOiJFUzM4NCIsInR5cCI6IkpXVCJ9...
token_type: Bearer
expires_in: 3600
refresh_token: OxGdVtZJ-mk49cFd38uRUw
scope: advisory:ingest vex:ingest
clientCredentials:
summary: Client credentials success response
value:
access_token: eyJhbGciOiJFUzM4NCIsInR5cCI6IkpXVCJ9...
token_type: Bearer
expires_in: 900
scope: effective:write findings:read
authorizationCode:
summary: Authorization code success response
value:
access_token: eyJhbGciOiJFUzM4NCIsInR5cCI6IkpXVCJ9...
token_type: Bearer
expires_in: 900
refresh_token: VxKpc9Vj9QjYV6gLrhQHTw
scope: ui.read authority:tenants.read
id_token: eyJhbGciOiJFUzM4NCIsImtpZCI6ImNvbnNvbGUifQ...
'400':
description: Malformed request, unsupported grant type, or invalid credentials.
content:
application/json:
schema:
$ref: '#/components/schemas/OAuthErrorResponse'
examples:
invalidProvider:
summary: Unknown identity provider hint
value:
error: invalid_request
error_description: "Unknown identity provider 'legacy-directory'."
invalidScope:
summary: Scope not permitted for client
value:
error: invalid_scope
error_description: Scope 'effective:write' is not permitted for this client.
'401':
description: Client authentication failed.
content:
application/json:
schema:
$ref: '#/components/schemas/OAuthErrorResponse'
examples:
badClientSecret:
summary: Invalid client secret
value:
error: invalid_client
error_description: Client authentication failed.
/revoke:
post:
tags:
- Authentication
summary: Revoke an access or refresh token
security:
- ClientSecretBasic: []
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/RevocationRequest'
examples:
revokeRefreshToken:
summary: Revoke refresh token after logout
value:
token: 0.rg9pVlsGzXE8Q
token_type_hint: refresh_token
responses:
'200':
description: Token revoked or already invalid. The response body is intentionally blank.
'400':
description: Malformed request.
content:
application/json:
schema:
$ref: '#/components/schemas/OAuthErrorResponse'
examples:
missingToken:
summary: Token parameter omitted
value:
error: invalid_request
error_description: The revocation request is missing the token parameter.
'401':
description: Client authentication failed.
content:
application/json:
schema:
$ref: '#/components/schemas/OAuthErrorResponse'
examples:
badClientSecret:
summary: Invalid client credentials
value:
error: invalid_client
error_description: Client authentication failed.
/introspect:
post:
tags:
- Authentication
summary: Introspect token state
description: Returns the active status and claims for a given token. Requires a privileged client.
security:
- ClientSecretBasic: []
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/IntrospectionRequest'
examples:
introspectToken:
summary: Validate an access token issued to Orchestrator
value:
token: eyJhbGciOiJFUzM4NCIsInR5cCI6IkpXVCJ9...
token_type_hint: access_token
responses:
'200':
description: Token state evaluated.
content:
application/json:
schema:
$ref: '#/components/schemas/IntrospectionResponse'
examples:
activeToken:
summary: Active token response
value:
active: true
scope: orch:operate orch:read
client_id: orch-control
sub: operator-7f12
username: ops.engineer@tenant.example
token_type: Bearer
exp: 1761628800
iat: 1761625200
nbf: 1761625200
iss: https://authority.stellaops.local
aud:
- https://orch.stellaops.local
jti: 01J8KYRAMG7FWBPRRV5XG20T7S
tenant: tenant-alpha
confirmation:
mtls_thumbprint: 079871b8c9a0f2e6
inactiveToken:
summary: Revoked token response
value:
active: false
'400':
description: Malformed request.
content:
application/json:
schema:
$ref: '#/components/schemas/OAuthErrorResponse'
examples:
missingToken:
summary: Token missing
value:
error: invalid_request
error_description: token parameter is required.
'401':
description: Client authentication failed or client lacks introspection permission.
content:
application/json:
schema:
$ref: '#/components/schemas/OAuthErrorResponse'
examples:
unauthorizedClient:
summary: Client not allowed to introspect tokens
value:
error: invalid_client
error_description: Client authentication failed.
/jwks:
get:
tags:
- Keys
summary: Retrieve signing keys
description: Returns the JSON Web Key Set used to validate Authority-issued tokens.
responses:
'200':
description: JWKS document.
headers:
Cache-Control:
schema:
type: string
description: Standard caching headers apply; keys rotate infrequently.
content:
application/json:
schema:
$ref: '#/components/schemas/JwksDocument'
examples:
ecKeySet:
summary: EC signing keys
value:
keys:
- kid: auth-tokens-es384-202510
kty: EC
use: sig
alg: ES384
crv: P-384
x: 7UchU5R77LtChrJx6uWg9mYjFvV6RIpSgZPDIj7d1q0
y: v98nHe8a7mGZ9Fn1t4Jp9PTJv1ma35QPmhUrE4pH7H0
status: active
- kid: auth-tokens-es384-202409
kty: EC
use: sig
alg: ES384
crv: P-384
x: hjdKc0r8jvVHJ7S9mP0y0mU9bqN7v5PxS21SwclTzfc
y: yk6J3pz4TUpymN4mG-6th3dYvJ5N1lQvDK0PLuFv3Pg
status: retiring

View File

@@ -11,10 +11,18 @@ public class StellaOpsScopesTests
[InlineData(StellaOpsScopes.VexRead)]
[InlineData(StellaOpsScopes.VexIngest)]
[InlineData(StellaOpsScopes.AocVerify)]
[InlineData(StellaOpsScopes.SignalsRead)]
[InlineData(StellaOpsScopes.SignalsWrite)]
[InlineData(StellaOpsScopes.SignalsAdmin)]
[InlineData(StellaOpsScopes.PolicyWrite)]
[InlineData(StellaOpsScopes.PolicyAuthor)]
[InlineData(StellaOpsScopes.PolicySubmit)]
[InlineData(StellaOpsScopes.PolicyApprove)]
[InlineData(StellaOpsScopes.PolicyReview)]
[InlineData(StellaOpsScopes.PolicyOperate)]
[InlineData(StellaOpsScopes.PolicyAudit)]
[InlineData(StellaOpsScopes.PolicyRun)]
[InlineData(StellaOpsScopes.PolicySimulate)]
[InlineData(StellaOpsScopes.FindingsRead)]
[InlineData(StellaOpsScopes.EffectiveWrite)]
[InlineData(StellaOpsScopes.GraphRead)]
@@ -22,6 +30,11 @@ public class StellaOpsScopesTests
[InlineData(StellaOpsScopes.GraphWrite)]
[InlineData(StellaOpsScopes.GraphExport)]
[InlineData(StellaOpsScopes.GraphSimulate)]
[InlineData(StellaOpsScopes.OrchRead)]
[InlineData(StellaOpsScopes.OrchOperate)]
[InlineData(StellaOpsScopes.ExportViewer)]
[InlineData(StellaOpsScopes.ExportOperator)]
[InlineData(StellaOpsScopes.ExportAdmin)]
public void All_IncludesNewScopes(string scope)
{
Assert.Contains(scope, StellaOpsScopes.All);
@@ -31,6 +44,9 @@ public class StellaOpsScopesTests
[InlineData("Advisory:Read", StellaOpsScopes.AdvisoryRead)]
[InlineData(" VEX:Ingest ", StellaOpsScopes.VexIngest)]
[InlineData("AOC:VERIFY", StellaOpsScopes.AocVerify)]
[InlineData(" Signals:Write ", StellaOpsScopes.SignalsWrite)]
[InlineData("Policy:Author", StellaOpsScopes.PolicyAuthor)]
[InlineData("Export.Admin", StellaOpsScopes.ExportAdmin)]
public void Normalize_NormalizesToLowerCase(string input, string expected)
{
Assert.Equal(expected, StellaOpsScopes.Normalize(input));

View File

@@ -15,6 +15,11 @@ public static class StellaOpsClaimTypes
/// </summary>
public const string Tenant = "stellaops:tenant";
/// <summary>
/// StellaOps project identifier claim (optional project scoping within a tenant).
/// </summary>
public const string Project = "stellaops:project";
/// <summary>
/// OAuth2/OIDC client identifier claim (maps to <c>client_id</c>).
/// </summary>

View File

@@ -1,29 +1,29 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Auth.Abstractions;
/// <summary>
/// Canonical scope names supported by StellaOps services.
/// </summary>
public static class StellaOpsScopes
{
/// <summary>
/// Scope required to trigger Concelier jobs.
/// </summary>
public const string ConcelierJobsTrigger = "concelier.jobs.trigger";
/// <summary>
/// Scope required to manage Concelier merge operations.
/// </summary>
public const string ConcelierMerge = "concelier.merge";
/// <summary>
/// Scope granting administrative access to Authority user management.
/// </summary>
public const string AuthorityUsersManage = "authority.users.manage";
/// <summary>
using System;
using System.Collections.Generic;
namespace StellaOps.Auth.Abstractions;
/// <summary>
/// Canonical scope names supported by StellaOps services.
/// </summary>
public static class StellaOpsScopes
{
/// <summary>
/// Scope required to trigger Concelier jobs.
/// </summary>
public const string ConcelierJobsTrigger = "concelier.jobs.trigger";
/// <summary>
/// Scope required to manage Concelier merge operations.
/// </summary>
public const string ConcelierMerge = "concelier.merge";
/// <summary>
/// Scope granting administrative access to Authority user management.
/// </summary>
public const string AuthorityUsersManage = "authority.users.manage";
/// <summary>
/// Scope granting administrative access to Authority client registrations.
/// </summary>
public const string AuthorityClientsManage = "authority.clients.manage";
@@ -38,6 +38,16 @@ public static class StellaOpsScopes
/// </summary>
public const string Bypass = "stellaops.bypass";
/// <summary>
/// Scope granting read-only access to console UX features.
/// </summary>
public const string UiRead = "ui.read";
/// <summary>
/// Scope granting permission to approve exceptions.
/// </summary>
public const string ExceptionsApprove = "exceptions:approve";
/// <summary>
/// Scope granting read-only access to raw advisory ingestion data.
/// </summary>
@@ -63,11 +73,46 @@ public static class StellaOpsScopes
/// </summary>
public const string AocVerify = "aoc:verify";
/// <summary>
/// Scope granting read-only access to reachability signals.
/// </summary>
public const string SignalsRead = "signals:read";
/// <summary>
/// Scope granting permission to write reachability signals.
/// </summary>
public const string SignalsWrite = "signals:write";
/// <summary>
/// Scope granting administrative access to reachability signal ingestion.
/// </summary>
public const string SignalsAdmin = "signals:admin";
/// <summary>
/// Scope granting permission to create or edit policy drafts.
/// </summary>
public const string PolicyWrite = "policy:write";
/// <summary>
/// Scope granting permission to author Policy Studio workspaces.
/// </summary>
public const string PolicyAuthor = "policy:author";
/// <summary>
/// Scope granting permission to edit policy configurations.
/// </summary>
public const string PolicyEdit = "policy:edit";
/// <summary>
/// Scope granting read-only access to policy metadata.
/// </summary>
public const string PolicyRead = "policy:read";
/// <summary>
/// Scope granting permission to review Policy Studio drafts.
/// </summary>
public const string PolicyReview = "policy:review";
/// <summary>
/// Scope granting permission to submit drafts for review.
/// </summary>
@@ -78,16 +123,36 @@ public static class StellaOpsScopes
/// </summary>
public const string PolicyApprove = "policy:approve";
/// <summary>
/// Scope granting permission to operate Policy Studio promotions and runs.
/// </summary>
public const string PolicyOperate = "policy:operate";
/// <summary>
/// Scope granting permission to audit Policy Studio activity.
/// </summary>
public const string PolicyAudit = "policy:audit";
/// <summary>
/// Scope granting permission to trigger policy runs and activation workflows.
/// </summary>
public const string PolicyRun = "policy:run";
/// <summary>
/// Scope granting permission to activate policies.
/// </summary>
public const string PolicyActivate = "policy:activate";
/// <summary>
/// Scope granting read-only access to effective findings materialised by Policy Engine.
/// </summary>
public const string FindingsRead = "findings:read";
/// <summary>
/// Scope granting permission to run Policy Studio simulations.
/// </summary>
public const string PolicySimulate = "policy:simulate";
/// <summary>
/// Scope granted to Policy Engine service identity for writing effective findings.
/// </summary>
@@ -103,6 +168,21 @@ public static class StellaOpsScopes
/// </summary>
public const string VulnRead = "vuln:read";
/// <summary>
/// Scope granting read-only access to export center runs and bundles.
/// </summary>
public const string ExportViewer = "export.viewer";
/// <summary>
/// Scope granting permission to operate export center scheduling and run execution.
/// </summary>
public const string ExportOperator = "export.operator";
/// <summary>
/// Scope granting administrative control over export center retention, encryption keys, and scheduling policies.
/// </summary>
public const string ExportAdmin = "export.admin";
/// <summary>
/// Scope granting permission to enqueue or mutate graph build jobs.
/// </summary>
@@ -118,6 +198,21 @@ public static class StellaOpsScopes
/// </summary>
public const string GraphSimulate = "graph:simulate";
/// <summary>
/// Scope granting read-only access to Orchestrator job state and telemetry.
/// </summary>
public const string OrchRead = "orch:read";
/// <summary>
/// Scope granting permission to execute Orchestrator control actions.
/// </summary>
public const string OrchOperate = "orch:operate";
/// <summary>
/// Scope granting read-only access to Authority tenant catalog APIs.
/// </summary>
public const string AuthorityTenantsRead = "authority:tenants.read";
private static readonly HashSet<string> KnownScopes = new(StringComparer.OrdinalIgnoreCase)
{
ConcelierJobsTrigger,
@@ -126,50 +221,69 @@ public static class StellaOpsScopes
AuthorityClientsManage,
AuthorityAuditRead,
Bypass,
UiRead,
ExceptionsApprove,
AdvisoryRead,
AdvisoryIngest,
VexRead,
VexIngest,
AocVerify,
SignalsRead,
SignalsWrite,
SignalsAdmin,
PolicyWrite,
PolicyAuthor,
PolicyEdit,
PolicyRead,
PolicyReview,
PolicySubmit,
PolicyApprove,
PolicyOperate,
PolicyAudit,
PolicyRun,
PolicyActivate,
PolicySimulate,
FindingsRead,
EffectiveWrite,
GraphRead,
VulnRead,
ExportViewer,
ExportOperator,
ExportAdmin,
GraphWrite,
GraphExport,
GraphSimulate
GraphSimulate,
OrchRead,
OrchOperate,
AuthorityTenantsRead
};
/// <summary>
/// Normalises a scope string (trim/convert to lower case).
/// </summary>
/// <param name="scope">Scope raw value.</param>
/// <returns>Normalised scope or <c>null</c> when the input is blank.</returns>
public static string? Normalize(string? scope)
{
if (string.IsNullOrWhiteSpace(scope))
{
return null;
}
return scope.Trim().ToLowerInvariant();
}
/// <summary>
/// Checks whether the provided scope is registered as a built-in StellaOps scope.
/// </summary>
public static bool IsKnown(string scope)
{
ArgumentNullException.ThrowIfNull(scope);
return KnownScopes.Contains(scope);
}
/// <summary>
/// Returns the full set of built-in scopes.
/// </summary>
public static IReadOnlyCollection<string> All => KnownScopes;
}
/// <summary>
/// Normalises a scope string (trim/convert to lower case).
/// </summary>
/// <param name="scope">Scope raw value.</param>
/// <returns>Normalised scope or <c>null</c> when the input is blank.</returns>
public static string? Normalize(string? scope)
{
if (string.IsNullOrWhiteSpace(scope))
{
return null;
}
return scope.Trim().ToLowerInvariant();
}
/// <summary>
/// Checks whether the provided scope is registered as a built-in StellaOps scope.
/// </summary>
public static bool IsKnown(string scope)
{
ArgumentNullException.ThrowIfNull(scope);
return KnownScopes.Contains(scope);
}
/// <summary>
/// Returns the full set of built-in scopes.
/// </summary>
public static IReadOnlyCollection<string> All => KnownScopes;
}

View File

@@ -19,4 +19,9 @@ public static class StellaOpsServiceIdentities
/// Service identity used by Vuln Explorer when issuing scoped permalink requests.
/// </summary>
public const string VulnExplorer = "vuln-explorer";
/// <summary>
/// Service identity used by Signals components when managing reachability facts.
/// </summary>
public const string Signals = "signals";
}

View File

@@ -0,0 +1,12 @@
namespace StellaOps.Auth.Abstractions;
/// <summary>
/// Shared tenancy default values used across StellaOps services.
/// </summary>
public static class StellaOpsTenancyDefaults
{
/// <summary>
/// Sentinel value indicating the token is not scoped to a specific project.
/// </summary>
public const string AnyProject = "*";
}

View File

@@ -1,3 +1,4 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.IdentityModel.Tokens;
@@ -12,12 +13,12 @@ public interface IStellaOpsTokenClient
/// <summary>
/// Requests an access token using the resource owner password credentials flow.
/// </summary>
Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, CancellationToken cancellationToken = default);
Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default);
/// <summary>
/// Requests an access token using the client credentials flow.
/// </summary>
Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, CancellationToken cancellationToken = default);
Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves the cached JWKS document.

View File

@@ -48,7 +48,12 @@ public sealed class StellaOpsTokenClient : IStellaOpsTokenClient
this.logger = logger;
}
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, CancellationToken cancellationToken = default)
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(
string username,
string password,
string? scope = null,
IReadOnlyDictionary<string, string>? additionalParameters = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(username);
ArgumentException.ThrowIfNullOrWhiteSpace(password);
@@ -70,10 +75,23 @@ public sealed class StellaOpsTokenClient : IStellaOpsTokenClient
AppendScope(parameters, scope, options);
if (additionalParameters is not null)
{
foreach (var (key, value) in additionalParameters)
{
if (string.IsNullOrWhiteSpace(key) || value is null)
{
continue;
}
parameters[key] = value;
}
}
return RequestTokenAsync(parameters, cancellationToken);
}
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, CancellationToken cancellationToken = default)
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
{
var options = optionsMonitor.CurrentValue;
if (string.IsNullOrWhiteSpace(options.ClientId))
@@ -94,6 +112,19 @@ public sealed class StellaOpsTokenClient : IStellaOpsTokenClient
AppendScope(parameters, scope, options);
if (additionalParameters is not null)
{
foreach (var (key, value) in additionalParameters)
{
if (string.IsNullOrWhiteSpace(key) || value is null)
{
continue;
}
parameters[key] = value;
}
}
return RequestTokenAsync(parameters, cancellationToken);
}

View File

@@ -38,7 +38,8 @@ public class ServiceCollectionExtensionsTests
Assert.Equal(new Uri("https://authority.example/"), new Uri(jwtOptions.Authority!));
Assert.True(jwtOptions.TokenValidationParameters.ValidateAudience);
Assert.Contains("api://concelier", jwtOptions.TokenValidationParameters.ValidAudiences);
Assert.Equal(TimeSpan.FromSeconds(60), jwtOptions.TokenValidationParameters.ClockSkew);
Assert.Equal(new[] { "concelier.jobs.trigger" }, resourceOptions.NormalizedScopes);
}
}
Assert.Equal(TimeSpan.FromSeconds(60), jwtOptions.TokenValidationParameters.ClockSkew);
Assert.Equal(new[] { "concelier.jobs.trigger" }, resourceOptions.NormalizedScopes);
Assert.IsType<StellaOpsAuthorityConfigurationManager>(jwtOptions.ConfigurationManager);
}
}

View File

@@ -37,6 +37,9 @@ public static class ServiceCollectionExtensions
services.AddAuthorization();
services.AddStellaOpsScopeHandler();
services.TryAddSingleton<StellaOpsBypassEvaluator>();
services.TryAddSingleton<TimeProvider>(_ => TimeProvider.System);
services.AddHttpClient(StellaOpsAuthorityConfigurationManager.HttpClientName);
services.AddSingleton<StellaOpsAuthorityConfigurationManager>();
var optionsBuilder = services.AddOptions<StellaOpsResourceServerOptions>();
if (!string.IsNullOrWhiteSpace(configurationSection))
@@ -60,7 +63,7 @@ public static class ServiceCollectionExtensions
authenticationBuilder.AddJwtBearer(StellaOpsAuthenticationDefaults.AuthenticationScheme);
services.AddOptions<JwtBearerOptions>(StellaOpsAuthenticationDefaults.AuthenticationScheme)
.Configure<IOptionsMonitor<StellaOpsResourceServerOptions>>((jwt, monitor) =>
.Configure<IServiceProvider, IOptionsMonitor<StellaOpsResourceServerOptions>>((jwt, provider, monitor) =>
{
var resourceOptions = monitor.CurrentValue;
@@ -81,6 +84,7 @@ public static class ServiceCollectionExtensions
jwt.TokenValidationParameters.ClockSkew = resourceOptions.TokenClockSkew;
jwt.TokenValidationParameters.NameClaimType = ClaimTypes.Name;
jwt.TokenValidationParameters.RoleClaimType = ClaimTypes.Role;
jwt.ConfigurationManager = provider.GetRequiredService<StellaOpsAuthorityConfigurationManager>();
});
return services;

View File

@@ -0,0 +1,116 @@
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
namespace StellaOps.Auth.ServerIntegration;
/// <summary>
/// Cached configuration manager for StellaOps Authority metadata and JWKS.
/// </summary>
internal sealed class StellaOpsAuthorityConfigurationManager : IConfigurationManager<OpenIdConnectConfiguration>
{
internal const string HttpClientName = "StellaOps.Auth.ServerIntegration.Metadata";
private readonly IHttpClientFactory httpClientFactory;
private readonly IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor;
private readonly TimeProvider timeProvider;
private readonly ILogger<StellaOpsAuthorityConfigurationManager> logger;
private readonly SemaphoreSlim refreshLock = new(1, 1);
private OpenIdConnectConfiguration? cachedConfiguration;
private DateTimeOffset cacheExpiresAt;
public StellaOpsAuthorityConfigurationManager(
IHttpClientFactory httpClientFactory,
IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor,
TimeProvider timeProvider,
ILogger<StellaOpsAuthorityConfigurationManager> logger)
{
this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<OpenIdConnectConfiguration> GetConfigurationAsync(CancellationToken cancellationToken)
{
var now = timeProvider.GetUtcNow();
var current = Volatile.Read(ref cachedConfiguration);
if (current is not null && now < cacheExpiresAt)
{
return current;
}
await refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (cachedConfiguration is not null && now < cacheExpiresAt)
{
return cachedConfiguration;
}
var options = optionsMonitor.CurrentValue;
var metadataAddress = ResolveMetadataAddress(options);
var httpClient = httpClientFactory.CreateClient(HttpClientName);
httpClient.Timeout = options.BackchannelTimeout;
var retriever = new HttpDocumentRetriever(httpClient)
{
RequireHttps = options.RequireHttpsMetadata
};
logger.LogDebug("Fetching OpenID Connect configuration from {MetadataAddress}.", metadataAddress);
var configuration = await OpenIdConnectConfigurationRetriever.GetAsync(metadataAddress, retriever, cancellationToken).ConfigureAwait(false);
configuration.Issuer ??= options.AuthorityUri.ToString();
if (!string.IsNullOrWhiteSpace(configuration.JwksUri))
{
logger.LogDebug("Fetching JWKS from {JwksUri}.", configuration.JwksUri);
var jwksDocument = await retriever.GetDocumentAsync(configuration.JwksUri, cancellationToken).ConfigureAwait(false);
var jsonWebKeySet = new JsonWebKeySet(jwksDocument);
configuration.SigningKeys.Clear();
foreach (JsonWebKey key in jsonWebKeySet.Keys)
{
configuration.SigningKeys.Add(key);
}
}
cachedConfiguration = configuration;
cacheExpiresAt = now + options.MetadataCacheLifetime;
return configuration;
}
finally
{
refreshLock.Release();
}
}
public void RequestRefresh()
{
Volatile.Write(ref cachedConfiguration, null);
cacheExpiresAt = DateTimeOffset.MinValue;
}
private static string ResolveMetadataAddress(StellaOpsResourceServerOptions options)
{
if (!string.IsNullOrWhiteSpace(options.MetadataAddress))
{
return options.MetadataAddress;
}
var authority = options.AuthorityUri;
if (!authority.AbsoluteUri.EndsWith("/", StringComparison.Ordinal))
{
authority = new Uri(authority.AbsoluteUri + "/", UriKind.Absolute);
}
return new Uri(authority, ".well-known/openid-configuration").AbsoluteUri;
}
}

View File

@@ -60,6 +60,11 @@ public sealed class StellaOpsResourceServerOptions
/// </summary>
public TimeSpan TokenClockSkew { get; set; } = TimeSpan.FromSeconds(60);
/// <summary>
/// Lifetime for cached discovery/JWKS metadata before forcing a refresh.
/// </summary>
public TimeSpan MetadataCacheLifetime { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Gets the canonical Authority URI (populated during validation).
/// </summary>
@@ -112,6 +117,11 @@ public sealed class StellaOpsResourceServerOptions
throw new InvalidOperationException("Resource server token clock skew must be between 0 seconds and 5 minutes.");
}
if (MetadataCacheLifetime <= TimeSpan.Zero || MetadataCacheLifetime > TimeSpan.FromHours(24))
{
throw new InvalidOperationException("Resource server metadata cache lifetime must be greater than zero and less than or equal to 24 hours.");
}
AuthorityUri = authorityUri;
NormalizeList(audiences, toLower: false);

View File

@@ -12,5 +12,6 @@ public static class AuthorityClientMetadataKeys
public const string PostLogoutRedirectUris = "postLogoutRedirectUris";
public const string SenderConstraint = "senderConstraint";
public const string Tenant = "tenant";
public const string Project = "project";
public const string ServiceIdentity = "serviceIdentity";
}

View File

@@ -652,12 +652,18 @@ public sealed record AuthorityClientDescriptor
AllowedAudiences = Normalize(allowedAudiences);
RedirectUris = redirectUris is null ? Array.Empty<Uri>() : redirectUris.ToArray();
PostLogoutRedirectUris = postLogoutRedirectUris is null ? Array.Empty<Uri>() : postLogoutRedirectUris.ToArray();
Properties = properties is null
var propertyBag = properties is null
? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
: new Dictionary<string, string?>(properties, StringComparer.OrdinalIgnoreCase);
Tenant = Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenantValue)
Tenant = propertyBag.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenantValue)
? AuthorityClientRegistration.NormalizeTenantValue(tenantValue)
: null;
var normalizedProject = propertyBag.TryGetValue(AuthorityClientMetadataKeys.Project, out var projectValue)
? AuthorityClientRegistration.NormalizeProjectValue(projectValue)
: null;
Project = normalizedProject ?? StellaOpsTenancyDefaults.AnyProject;
propertyBag[AuthorityClientMetadataKeys.Project] = Project;
Properties = propertyBag;
}
public string ClientId { get; }
@@ -669,6 +675,7 @@ public sealed record AuthorityClientDescriptor
public IReadOnlyCollection<Uri> RedirectUris { get; }
public IReadOnlyCollection<Uri> PostLogoutRedirectUris { get; }
public string? Tenant { get; }
public string? Project { get; }
public IReadOnlyDictionary<string, string?> Properties { get; }
private static IReadOnlyCollection<string> Normalize(IReadOnlyCollection<string>? values)
@@ -781,6 +788,7 @@ public sealed record AuthorityClientRegistration
IReadOnlyCollection<Uri>? redirectUris = null,
IReadOnlyCollection<Uri>? postLogoutRedirectUris = null,
string? tenant = null,
string? project = null,
IReadOnlyDictionary<string, string?>? properties = null,
IReadOnlyCollection<AuthorityClientCertificateBindingRegistration>? certificateBindings = null)
{
@@ -796,9 +804,13 @@ public sealed record AuthorityClientRegistration
RedirectUris = redirectUris is null ? Array.Empty<Uri>() : redirectUris.ToArray();
PostLogoutRedirectUris = postLogoutRedirectUris is null ? Array.Empty<Uri>() : postLogoutRedirectUris.ToArray();
Tenant = NormalizeTenantValue(tenant);
Properties = properties is null
var propertyBag = properties is null
? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
: new Dictionary<string, string?>(properties, StringComparer.OrdinalIgnoreCase);
var normalizedProject = NormalizeProjectValue(project ?? (propertyBag.TryGetValue(AuthorityClientMetadataKeys.Project, out var projectValue) ? projectValue : null));
Project = normalizedProject ?? StellaOpsTenancyDefaults.AnyProject;
propertyBag[AuthorityClientMetadataKeys.Project] = Project;
Properties = propertyBag;
CertificateBindings = certificateBindings is null
? Array.Empty<AuthorityClientCertificateBindingRegistration>()
: certificateBindings.ToArray();
@@ -814,11 +826,12 @@ public sealed record AuthorityClientRegistration
public IReadOnlyCollection<Uri> RedirectUris { get; }
public IReadOnlyCollection<Uri> PostLogoutRedirectUris { get; }
public string? Tenant { get; }
public string? Project { get; }
public IReadOnlyDictionary<string, string?> Properties { get; }
public IReadOnlyCollection<AuthorityClientCertificateBindingRegistration> CertificateBindings { get; }
public AuthorityClientRegistration WithClientSecret(string? clientSecret)
=> new(ClientId, Confidential, DisplayName, clientSecret, AllowedGrantTypes, AllowedScopes, AllowedAudiences, RedirectUris, PostLogoutRedirectUris, Tenant, Properties, CertificateBindings);
=> new(ClientId, Confidential, DisplayName, clientSecret, AllowedGrantTypes, AllowedScopes, AllowedAudiences, RedirectUris, PostLogoutRedirectUris, Tenant, Project, Properties, CertificateBindings);
private static IReadOnlyCollection<string> Normalize(IReadOnlyCollection<string>? values)
=> values is null || values.Count == 0
@@ -867,6 +880,16 @@ public sealed record AuthorityClientRegistration
return value.Trim().ToLowerInvariant();
}
internal static string? NormalizeProjectValue(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim().ToLowerInvariant();
}
private static string ValidateRequired(string value, string paramName)
=> string.IsNullOrWhiteSpace(value)
? throw new ArgumentException("Value cannot be null or whitespace.", paramName)

View File

@@ -5,7 +5,14 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateMSBuildEditorConfigFile>false</GenerateMSBuildEditorConfigFile>
</PropertyGroup>
<ItemGroup>
<EditorConfigFiles Remove="$(IntermediateOutputPath)$(MSBuildProjectName).GeneratedMSBuildEditorConfig.editorconfig" />
</ItemGroup>
<Target Name="EnsureGeneratedEditorConfig" BeforeTargets="ResolveEditorConfigFiles">
<WriteLinesToFile File="$(IntermediateOutputPath)$(MSBuildProjectName).GeneratedMSBuildEditorConfig.editorconfig" Lines="" Overwrite="false" />
</Target>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" />

View File

@@ -78,6 +78,10 @@ public sealed class AuthorityTokenDocument
[BsonIgnoreIfNull]
public string? Tenant { get; set; }
[BsonElement("project")]
[BsonIgnoreIfNull]
public string? Project { get; set; }
[BsonElement("devices")]
[BsonIgnoreIfNull]
public List<BsonDocument>? Devices { get; set; }

View File

@@ -0,0 +1,339 @@
using System.Net;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Text.Json;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using OpenIddict.Abstractions;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Authority.Console;
using StellaOps.Authority.Tenants;
using StellaOps.Cryptography.Audit;
using Xunit;
namespace StellaOps.Authority.Tests.Console;
public sealed class ConsoleEndpointsTests
{
[Fact]
public async Task Tenants_ReturnsTenant_WhenHeaderMatchesClaim()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-10-31T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.AuthorityTenantsRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(5));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
var response = await client.GetAsync("/console/tenants");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadAsStringAsync();
using var json = JsonDocument.Parse(payload);
var tenants = json.RootElement.GetProperty("tenants");
Assert.Equal(1, tenants.GetArrayLength());
Assert.Equal("tenant-default", tenants[0].GetProperty("id").GetString());
var audit = Assert.Single(sink.Events);
Assert.Equal("authority.console.tenants.read", audit.EventType);
Assert.Equal(AuthEventOutcome.Success, audit.Outcome);
Assert.Contains("tenant.resolved", audit.Properties.Select(property => property.Name));
}
[Fact]
public async Task Tenants_ReturnsBadRequest_WhenHeaderMissing()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-10-31T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.AuthorityTenantsRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(5));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
var response = await client.GetAsync("/console/tenants");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Empty(sink.Events);
}
[Fact]
public async Task Tenants_ReturnsForbid_WhenHeaderDoesNotMatchClaim()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-10-31T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.AuthorityTenantsRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(5));
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "other-tenant");
var response = await client.GetAsync("/console/tenants");
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Empty(sink.Events);
}
[Fact]
public async Task Profile_ReturnsProfileMetadata()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-10-31T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.AuthorityTenantsRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(5),
issuedAt: timeProvider.GetUtcNow().AddMinutes(-1),
authenticationTime: timeProvider.GetUtcNow().AddMinutes(-1),
subject: "user-123",
username: "console-user",
displayName: "Console User");
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = principal;
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
var response = await client.GetAsync("/console/profile");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadAsStringAsync();
using var json = JsonDocument.Parse(payload);
Assert.Equal("user-123", json.RootElement.GetProperty("subjectId").GetString());
Assert.Equal("console-user", json.RootElement.GetProperty("username").GetString());
Assert.Equal("tenant-default", json.RootElement.GetProperty("tenant").GetString());
var audit = Assert.Single(sink.Events);
Assert.Equal("authority.console.profile.read", audit.EventType);
Assert.Equal(AuthEventOutcome.Success, audit.Outcome);
}
[Fact]
public async Task TokenIntrospect_FlagsInactive_WhenExpired()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-10-31T12:00:00Z"));
var sink = new RecordingAuthEventSink();
await using var app = await CreateApplicationAsync(timeProvider, sink, new AuthorityTenantView("tenant-default", "Default", "active", "shared", Array.Empty<string>(), Array.Empty<string>()));
var principal = CreatePrincipal(
tenant: "tenant-default",
scopes: new[] { StellaOpsScopes.UiRead, StellaOpsScopes.AuthorityTenantsRead },
expiresAt: timeProvider.GetUtcNow().AddMinutes(-1),
issuedAt: timeProvider.GetUtcNow().AddMinutes(-10),
tokenId: "token-abc");
var accessor = app.Services.GetRequiredService<TestPrincipalAccessor>();
accessor.Principal = principal;
var client = app.CreateTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthenticationDefaults.AuthenticationScheme);
client.DefaultRequestHeaders.Add(AuthorityHttpHeaders.Tenant, "tenant-default");
var response = await client.PostAsync("/console/token/introspect", content: null);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadAsStringAsync();
using var json = JsonDocument.Parse(payload);
Assert.False(json.RootElement.GetProperty("active").GetBoolean());
Assert.Equal("token-abc", json.RootElement.GetProperty("tokenId").GetString());
var audit = Assert.Single(sink.Events);
Assert.Equal("authority.console.token.introspect", audit.EventType);
Assert.Equal(AuthEventOutcome.Success, audit.Outcome);
}
private static ClaimsPrincipal CreatePrincipal(
string tenant,
IReadOnlyCollection<string> scopes,
DateTimeOffset expiresAt,
DateTimeOffset? issuedAt = null,
DateTimeOffset? authenticationTime = null,
string? subject = null,
string? username = null,
string? displayName = null,
string? tokenId = null)
{
var claims = new List<Claim>
{
new(StellaOpsClaimTypes.Tenant, tenant),
new(StellaOpsClaimTypes.Scope, string.Join(' ', scopes)),
new("exp", expiresAt.ToUnixTimeSeconds().ToString()),
new(OpenIddictConstants.Claims.Audience, "console")
};
if (!string.IsNullOrWhiteSpace(subject))
{
claims.Add(new Claim(StellaOpsClaimTypes.Subject, subject));
}
if (!string.IsNullOrWhiteSpace(username))
{
claims.Add(new Claim(OpenIddictConstants.Claims.PreferredUsername, username));
}
if (!string.IsNullOrWhiteSpace(displayName))
{
claims.Add(new Claim(OpenIddictConstants.Claims.Name, displayName));
}
if (issuedAt is not null)
{
claims.Add(new Claim("iat", issuedAt.Value.ToUnixTimeSeconds().ToString()));
}
if (authenticationTime is not null)
{
claims.Add(new Claim("auth_time", authenticationTime.Value.ToUnixTimeSeconds().ToString()));
}
if (!string.IsNullOrWhiteSpace(tokenId))
{
claims.Add(new Claim(StellaOpsClaimTypes.TokenId, tokenId));
}
var identity = new ClaimsIdentity(claims, TestAuthenticationDefaults.AuthenticationScheme);
return new ClaimsPrincipal(identity);
}
private static async Task<WebApplication> CreateApplicationAsync(
FakeTimeProvider timeProvider,
RecordingAuthEventSink sink,
params AuthorityTenantView[] tenants)
{
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
{
EnvironmentName = Environments.Development
});
builder.WebHost.UseTestServer();
builder.Services.AddSingleton<TimeProvider>(timeProvider);
builder.Services.AddSingleton<IAuthEventSink>(sink);
builder.Services.AddSingleton<IAuthorityTenantCatalog>(new FakeTenantCatalog(tenants));
builder.Services.AddSingleton<TestPrincipalAccessor>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<StellaOpsBypassEvaluator>();
var authBuilder = builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = TestAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = TestAuthenticationDefaults.AuthenticationScheme;
});
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthenticationHandler>(TestAuthenticationDefaults.AuthenticationScheme, static _ => { });
authBuilder.AddScheme<AuthenticationSchemeOptions, TestAuthenticationHandler>(StellaOpsAuthenticationDefaults.AuthenticationScheme, static _ => { });
builder.Services.AddAuthorization();
builder.Services.AddStellaOpsScopeHandler();
builder.Services.AddOptions<StellaOpsResourceServerOptions>()
.Configure(options =>
{
options.Authority = "https://authority.integration.test";
})
.PostConfigure(static options => options.Validate());
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapConsoleEndpoints();
await app.StartAsync().ConfigureAwait(false);
return app;
}
private sealed class FakeTenantCatalog : IAuthorityTenantCatalog
{
private readonly IReadOnlyList<AuthorityTenantView> tenants;
public FakeTenantCatalog(IEnumerable<AuthorityTenantView> tenants)
{
this.tenants = tenants.ToArray();
}
public IReadOnlyList<AuthorityTenantView> GetTenants() => tenants;
}
private sealed class RecordingAuthEventSink : IAuthEventSink
{
public List<AuthEventRecord> Events { get; } = new();
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
{
Events.Add(record);
return ValueTask.CompletedTask;
}
}
private sealed class TestPrincipalAccessor
{
public ClaimsPrincipal? Principal { get; set; }
}
private sealed class TestAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public TestAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock)
: base(options, logger, encoder, clock)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var accessor = Context.RequestServices.GetRequiredService<TestPrincipalAccessor>();
if (accessor.Principal is null)
{
return Task.FromResult(AuthenticateResult.Fail("No principal configured."));
}
var ticket = new AuthenticationTicket(accessor.Principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
}
internal static class HostTestClientExtensions
{
public static HttpClient CreateTestClient(this WebApplication app)
{
var server = app.Services.GetRequiredService<IServer>() as TestServer
?? throw new InvalidOperationException("TestServer is not available. Ensure UseTestServer() is configured.");
return server.CreateClient();
}
}
internal static class TestAuthenticationDefaults
{
public const string AuthenticationScheme = "AuthorityConsoleTests";
}

View File

@@ -0,0 +1,48 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Hosting;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Mongo2Go;
using Xunit;
namespace StellaOps.Authority.Tests.Infrastructure;
public sealed class AuthorityWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly MongoDbRunner mongoRunner;
public AuthorityWebApplicationFactory()
{
mongoRunner = MongoDbRunner.Start(singleNodeReplSet: true);
}
public string ConnectionString => mongoRunner.ConnectionString;
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Development");
builder.ConfigureAppConfiguration((_, configuration) =>
{
var settings = new Dictionary<string, string?>
{
["Authority:Issuer"] = "https://authority.test",
["Authority:Storage:ConnectionString"] = mongoRunner.ConnectionString,
["Authority:Storage:DatabaseName"] = "authority-tests",
["Authority:Signing:Enabled"] = "false"
};
configuration.AddInMemoryCollection(settings);
});
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync()
{
mongoRunner.Dispose();
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,90 @@
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Authority.Tests.Infrastructure;
using StellaOps.Configuration;
using Xunit;
namespace StellaOps.Authority.Tests.OpenApi;
public sealed class OpenApiDiscoveryEndpointTests : IClassFixture<AuthorityWebApplicationFactory>
{
private readonly AuthorityWebApplicationFactory factory;
public OpenApiDiscoveryEndpointTests(AuthorityWebApplicationFactory factory)
{
this.factory = factory;
}
[Fact]
public async Task ReturnsJsonSpecificationByDefault()
{
using var client = factory.CreateClient();
using var response = await client.GetAsync("/.well-known/openapi").ConfigureAwait(false);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(response.Headers.ETag);
Assert.Equal("public, max-age=300", response.Headers.CacheControl?.ToString());
var contentType = response.Content.Headers.ContentType?.ToString();
Assert.Equal("application/openapi+json; charset=utf-8", contentType);
var payload = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
using var document = JsonDocument.Parse(payload);
Assert.Equal("3.1.0", document.RootElement.GetProperty("openapi").GetString());
var info = document.RootElement.GetProperty("info");
Assert.Equal("authority", info.GetProperty("x-stella-service").GetString());
Assert.True(info.TryGetProperty("x-stella-grant-types", out var grantsNode));
Assert.Contains("authorization_code", grantsNode.EnumerateArray().Select(element => element.GetString()));
var grantsHeader = Assert.Single(response.Headers.GetValues("X-StellaOps-OAuth-Grants"));
Assert.Contains("authorization_code", grantsHeader.Split(' ', StringSplitOptions.RemoveEmptyEntries));
var scopesHeader = Assert.Single(response.Headers.GetValues("X-StellaOps-OAuth-Scopes"));
Assert.Contains("policy:read", scopesHeader.Split(' ', StringSplitOptions.RemoveEmptyEntries));
}
[Fact]
public async Task ReturnsYamlWhenRequested()
{
using var client = factory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Get, "/.well-known/openapi");
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/openapi+yaml"));
using var response = await client.SendAsync(request).ConfigureAwait(false);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("application/openapi+yaml; charset=utf-8", response.Content.Headers.ContentType?.ToString());
var payload = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
Assert.StartsWith("openapi: 3.1.0", payload.TrimStart(), StringComparison.Ordinal);
}
[Fact]
public async Task ReturnsNotModifiedWhenEtagMatches()
{
using var client = factory.CreateClient();
using var initialResponse = await client.GetAsync("/.well-known/openapi").ConfigureAwait(false);
var etag = initialResponse.Headers.ETag;
Assert.NotNull(etag);
using var conditionalRequest = new HttpRequestMessage(HttpMethod.Get, "/.well-known/openapi");
conditionalRequest.Headers.IfNoneMatch.Add(etag!);
using var conditionalResponse = await client.SendAsync(conditionalRequest).ConfigureAwait(false);
Assert.Equal(HttpStatusCode.NotModified, conditionalResponse.StatusCode);
Assert.Equal(etag!.Tag, conditionalResponse.Headers.ETag?.Tag);
Assert.Equal("public, max-age=300", conditionalResponse.Headers.CacheControl?.ToString());
Assert.True(conditionalResponse.Content.Headers.ContentLength == 0 || conditionalResponse.Content.Headers.ContentLength is null);
}
}

View File

@@ -143,6 +143,206 @@ public class ClientCredentialsHandlersTests
Assert.Equal(new[] { "advisory:ingest" }, grantedScopes);
}
[Fact]
public async Task ValidateClientCredentials_RejectsAdvisoryReadWithoutAocVerify()
{
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "advisory:read aoc:verify",
tenant: "tenant-alpha");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "advisory:read");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error);
Assert.Equal("Scope 'aoc:verify' is required when requesting advisory/vex read scopes.", context.ErrorDescription);
Assert.Equal(StellaOpsScopes.AocVerify, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]);
}
[Fact]
public async Task ValidateClientCredentials_RejectsSignalsScopeWithoutAocVerify()
{
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "signals:read signals:write signals:admin aoc:verify",
tenant: "tenant-alpha");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "signals:write");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error);
Assert.Equal("Scope 'aoc:verify' is required when requesting signals scopes.", context.ErrorDescription);
Assert.Equal(StellaOpsScopes.AocVerify, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]);
}
[Fact]
public async Task ValidateClientCredentials_RejectsPolicyAuthorWithoutTenant()
{
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "policy:author");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "policy:author");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
Assert.Equal("Policy Studio scopes require a tenant assignment.", context.ErrorDescription);
Assert.Equal(StellaOpsScopes.PolicyAuthor, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]);
}
[Fact]
public async Task ValidateClientCredentials_AllowsPolicyAuthorWithTenant()
{
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "policy:author",
tenant: "tenant-alpha");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "policy:author");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}");
var grantedScopes = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]);
Assert.Equal(new[] { "policy:author" }, grantedScopes);
}
[Fact]
public async Task ValidateClientCredentials_AllowsAdvisoryReadWithAocVerify()
{
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "advisory:read aoc:verify",
tenant: "tenant-alpha");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "advisory:read aoc:verify");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}");
var grantedScopes = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]);
Assert.Equal(new[] { "advisory:read", "aoc:verify" }, grantedScopes);
}
[Fact]
public async Task ValidateClientCredentials_RejectsAocVerifyWithoutTenant()
{
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "aoc:verify");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "aoc:verify");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
Assert.Equal("Scope 'aoc:verify' requires a tenant assignment.", context.ErrorDescription);
Assert.Equal(StellaOpsScopes.AocVerify, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]);
}
[Fact]
public async Task ValidateClientCredentials_RejectsEffectiveWrite_WhenServiceIdentityMissing()
{
@@ -252,6 +452,330 @@ public class ClientCredentialsHandlersTests
Assert.Equal("tenant-default", tenant);
}
[Fact]
public async Task ValidateClientCredentials_RejectsOrchOperate_WhenTenantMissing()
{
var clientDocument = CreateClient(
clientId: "orch-operator",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "orch:read orch:operate");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:operate");
transaction.Request?.SetParameter(AuthorityOpenIddictConstants.OperatorReasonParameterName, "resume source after maintenance");
transaction.Request?.SetParameter(AuthorityOpenIddictConstants.OperatorTicketParameterName, "INC-2045");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
Assert.Equal("Orchestrator scopes require a tenant assignment.", context.ErrorDescription);
}
[Fact]
public async Task ValidateClientCredentials_RejectsOrchOperate_WhenReasonMissing()
{
var clientDocument = CreateClient(
clientId: "orch-operator",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "orch:read orch:operate",
tenant: "tenant-default");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:operate");
transaction.Request?.SetParameter(AuthorityOpenIddictConstants.OperatorTicketParameterName, "INC-2045");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
Assert.Equal("Operator actions require 'operator_reason'.", context.ErrorDescription);
}
[Fact]
public async Task ValidateClientCredentials_RejectsOrchOperate_WhenTicketMissing()
{
var clientDocument = CreateClient(
clientId: "orch-operator",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "orch:read orch:operate",
tenant: "tenant-default");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:operate");
transaction.Request?.SetParameter(AuthorityOpenIddictConstants.OperatorReasonParameterName, "resume source after maintenance");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
Assert.Equal("Operator actions require 'operator_ticket'.", context.ErrorDescription);
}
[Fact]
public async Task ValidateClientCredentials_AllowsOrchOperate_WithReasonAndTicket()
{
var clientDocument = CreateClient(
clientId: "orch-operator",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "orch:read orch:operate",
tenant: "tenant-default");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:operate");
transaction.Request?.SetParameter(AuthorityOpenIddictConstants.OperatorReasonParameterName, "resume source after maintenance");
transaction.Request?.SetParameter(AuthorityOpenIddictConstants.OperatorTicketParameterName, "INC-2045");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}");
var grantedScopes = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]);
Assert.Equal(new[] { "orch:operate" }, grantedScopes);
var tenant = Assert.IsType<string>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]);
Assert.Equal("tenant-default", tenant);
var reason = Assert.IsType<string>(context.Transaction.Properties[AuthorityOpenIddictConstants.OperatorReasonProperty]);
Assert.Equal("resume source after maintenance", reason);
var ticket = Assert.IsType<string>(context.Transaction.Properties[AuthorityOpenIddictConstants.OperatorTicketProperty]);
Assert.Equal("INC-2045", ticket);
}
[Fact]
public async Task ValidateClientCredentials_RejectsExportViewer_WhenTenantMissing()
{
var clientDocument = CreateClient(
clientId: "export-viewer",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "export.viewer");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
TestHelpers.CreateAuthorityOptions(),
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "export.viewer");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
Assert.Equal("Export scopes require a tenant assignment.", context.ErrorDescription);
Assert.Equal(StellaOpsScopes.ExportViewer, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]);
}
[Fact]
public async Task ValidateClientCredentials_AllowsExportViewer_WithTenant()
{
var clientDocument = CreateClient(
clientId: "export-viewer",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "export.viewer",
tenant: "tenant-default");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
TestHelpers.CreateAuthorityOptions(),
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "export.viewer");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}");
var grantedScopes = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]);
Assert.Equal(new[] { "export.viewer" }, grantedScopes);
var tenant = Assert.IsType<string>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]);
Assert.Equal("tenant-default", tenant);
}
[Fact]
public async Task ValidateClientCredentials_RejectsExportAdmin_WhenReasonMissing()
{
var clientDocument = CreateClient(
clientId: "export-admin",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "export.admin",
tenant: "tenant-default");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
TestHelpers.CreateAuthorityOptions(),
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "export.admin");
transaction.Request?.SetParameter(AuthorityOpenIddictConstants.ExportAdminTicketParameterName, "INC-9001");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
Assert.Equal("Export admin actions require 'export_reason'.", context.ErrorDescription);
}
[Fact]
public async Task ValidateClientCredentials_RejectsExportAdmin_WhenTicketMissing()
{
var clientDocument = CreateClient(
clientId: "export-admin",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "export.admin",
tenant: "tenant-default");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
TestHelpers.CreateAuthorityOptions(),
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "export.admin");
transaction.Request?.SetParameter(AuthorityOpenIddictConstants.ExportAdminReasonParameterName, "Rotate encryption keys after incident postmortem");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidRequest, context.Error);
Assert.Equal("Export admin actions require 'export_ticket'.", context.ErrorDescription);
}
[Fact]
public async Task ValidateClientCredentials_AllowsExportAdmin_WithReasonAndTicket()
{
var clientDocument = CreateClient(
clientId: "export-admin",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "export.admin",
tenant: "tenant-default");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
TestHelpers.CreateAuthorityOptions(),
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "export.admin");
transaction.Request?.SetParameter(AuthorityOpenIddictConstants.ExportAdminReasonParameterName, "Rotate encryption keys after incident postmortem");
transaction.Request?.SetParameter(AuthorityOpenIddictConstants.ExportAdminTicketParameterName, "INC-9001");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}");
var grantedScopes = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]);
Assert.Equal(new[] { "export.admin" }, grantedScopes);
var tenant = Assert.IsType<string>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]);
Assert.Equal("tenant-default", tenant);
var reason = Assert.IsType<string>(context.Transaction.Properties[AuthorityOpenIddictConstants.ExportAdminReasonProperty]);
Assert.Equal("Rotate encryption keys after incident postmortem", reason);
var ticket = Assert.IsType<string>(context.Transaction.Properties[AuthorityOpenIddictConstants.ExportAdminTicketProperty]);
Assert.Equal("INC-9001", ticket);
}
[Fact]
public async Task ValidateClientCredentials_RejectsGraphWrite_WhenServiceIdentityMissing()
{
@@ -390,6 +914,75 @@ public class ClientCredentialsHandlersTests
Assert.Equal("tenant-default", tenant);
}
[Fact]
public async Task ValidateClientCredentials_RejectsOrchRead_WhenTenantMissing()
{
var clientDocument = CreateClient(
clientId: "orch-dashboard",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "orch:read");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:read");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
Assert.Equal("Orchestrator scopes require a tenant assignment.", context.ErrorDescription);
}
[Fact]
public async Task ValidateClientCredentials_AllowsOrchRead_WithTenant()
{
var clientDocument = CreateClient(
clientId: "orch-dashboard",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "orch:read",
tenant: "tenant-default");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var options = TestHelpers.CreateAuthorityOptions();
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "orch:read");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}");
var grantedScopes = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]);
Assert.Equal(new[] { "orch:read" }, grantedScopes);
var tenant = Assert.IsType<string>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]);
Assert.Equal("tenant-default", tenant);
}
[Fact]
public async Task ValidateClientCredentials_RejectsAdvisoryScopes_WhenTenantMissing()
{
@@ -463,7 +1056,7 @@ public class ClientCredentialsHandlersTests
clientId: "concelier-ingestor",
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "advisory:ingest advisory:read",
allowedScopes: "advisory:ingest advisory:read aoc:verify",
tenant: "tenant-default");
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
@@ -480,14 +1073,14 @@ public class ClientCredentialsHandlersTests
options,
NullLogger<ValidateClientCredentialsHandler>.Instance);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "advisory:read");
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "advisory:read aoc:verify");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}");
var grantedScopes = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]);
Assert.Equal(new[] { "advisory:read" }, grantedScopes);
Assert.Equal(new[] { "advisory:read", "aoc:verify" }, grantedScopes);
}
[Fact]
@@ -1141,6 +1734,10 @@ public class TokenValidationHandlersTests
Assert.False(context.IsRejected);
Assert.Equal("tenant-alpha", principal.FindFirstValue(StellaOpsClaimTypes.Tenant));
Assert.Equal("tenant-alpha", metadataAccessor.GetMetadata()?.Tenant);
Assert.Equal(StellaOpsTenancyDefaults.AnyProject, principal.FindFirstValue(StellaOpsClaimTypes.Project));
Assert.Equal(StellaOpsTenancyDefaults.AnyProject, metadataAccessor.GetMetadata()?.Project);
Assert.Equal(StellaOpsTenancyDefaults.AnyProject, principal.FindFirstValue(StellaOpsClaimTypes.Project));
Assert.Equal(StellaOpsTenancyDefaults.AnyProject, metadataAccessor.GetMetadata()?.Project);
}
[Fact]
@@ -1799,6 +2396,12 @@ internal sealed class TestRateLimiterMetadataAccessor : IAuthorityRateLimiterMet
metadata.SetTag("authority.tenant", metadata.Tenant);
}
public void SetProject(string? project)
{
metadata.Project = string.IsNullOrWhiteSpace(project) ? null : project.Trim().ToLowerInvariant();
metadata.SetTag("authority.project", metadata.Project);
}
public void SetTag(string name, string? value) => metadata.SetTag(name, value);
}
@@ -2060,6 +2663,7 @@ internal static class TestHelpers
identity.AddClaim(new Claim(OpenIddictConstants.Claims.ClientId, clientId));
identity.AddClaim(new Claim(OpenIddictConstants.Claims.JwtId, tokenId));
identity.AddClaim(new Claim(StellaOpsClaimTypes.IdentityProvider, provider));
identity.AddClaim(new Claim(StellaOpsClaimTypes.Project, StellaOpsTenancyDefaults.AnyProject));
if (!string.IsNullOrWhiteSpace(subject))
{

View File

@@ -7,7 +7,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using OpenIddict.Abstractions;
using OpenIddict.Server;
@@ -19,6 +19,8 @@ using StellaOps.Authority.RateLimiting;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Cryptography.Audit;
using StellaOps.Configuration;
using StellaOps.Auth.Abstractions;
using Xunit;
namespace StellaOps.Authority.Tests.OpenIddict;
@@ -34,8 +36,10 @@ public class PasswordGrantHandlersTests
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore());
var clientStore = new StubClientStore(CreateClientDocument());
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance);
var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.Instance);
var authorityOptions = CreateAuthorityOptions();
var optionsAccessor = Options.Create(authorityOptions);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, optionsAccessor, NullLogger<ValidatePasswordGrantHandler>.Instance);
var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, optionsAccessor, NullLogger<HandlePasswordGrantHandler>.Instance);
var transaction = CreatePasswordTransaction("alice", "Password1!");
@@ -56,8 +60,10 @@ public class PasswordGrantHandlersTests
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new FailureCredentialStore());
var clientStore = new StubClientStore(CreateClientDocument());
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance);
var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.Instance);
var authorityOptions = CreateAuthorityOptions();
var optionsAccessor = Options.Create(authorityOptions);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, optionsAccessor, NullLogger<ValidatePasswordGrantHandler>.Instance);
var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, optionsAccessor, NullLogger<HandlePasswordGrantHandler>.Instance);
var transaction = CreatePasswordTransaction("alice", "BadPassword!");
@@ -67,6 +73,97 @@ public class PasswordGrantHandlersTests
Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Failure);
}
[Fact]
public async Task ValidatePasswordGrant_RejectsAdvisoryReadWithoutAocVerify()
{
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore());
var clientStore = new StubClientStore(CreateClientDocument("advisory:read aoc:verify"));
var authorityOptions = CreateAuthorityOptions();
var optionsAccessor = Options.Create(authorityOptions);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, optionsAccessor, NullLogger<ValidatePasswordGrantHandler>.Instance);
var transaction = CreatePasswordTransaction("alice", "Password1!", "advisory:read");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await validate.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error);
Assert.Equal("Scope 'aoc:verify' is required when requesting advisory/vex read scopes.", context.ErrorDescription);
Assert.Equal(StellaOpsScopes.AocVerify, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]);
Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Failure);
}
[Fact]
public async Task ValidatePasswordGrant_RejectsSignalsScopeWithoutAocVerify()
{
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore());
var clientStore = new StubClientStore(CreateClientDocument("signals:write signals:read signals:admin aoc:verify"));
var authorityOptions = CreateAuthorityOptions();
var optionsAccessor = Options.Create(authorityOptions);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, optionsAccessor, NullLogger<ValidatePasswordGrantHandler>.Instance);
var transaction = CreatePasswordTransaction("alice", "Password1!", "signals:write");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await validate.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error);
Assert.Equal("Scope 'aoc:verify' is required when requesting signals scopes.", context.ErrorDescription);
Assert.Equal(StellaOpsScopes.AocVerify, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]);
Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Failure);
}
[Fact]
public async Task ValidatePasswordGrant_RejectsPolicyAuthorWithoutTenant()
{
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore());
var clientDocument = CreateClientDocument("policy:author");
clientDocument.Properties.Remove(AuthorityClientMetadataKeys.Tenant);
var clientStore = new StubClientStore(clientDocument);
var authorityOptions = CreateAuthorityOptions();
var optionsAccessor = Options.Create(authorityOptions);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, optionsAccessor, NullLogger<ValidatePasswordGrantHandler>.Instance);
var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:author");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await validate.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
Assert.Equal("Policy Studio scopes require a tenant assignment.", context.ErrorDescription);
Assert.Equal(StellaOpsScopes.PolicyAuthor, context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty]);
Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Failure);
}
[Fact]
public async Task ValidatePasswordGrant_AllowsPolicyAuthor()
{
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore());
var clientStore = new StubClientStore(CreateClientDocument("policy:author"));
var authorityOptions = CreateAuthorityOptions();
var optionsAccessor = Options.Create(authorityOptions);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, optionsAccessor, NullLogger<ValidatePasswordGrantHandler>.Instance);
var transaction = CreatePasswordTransaction("alice", "Password1!", "policy:author");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await validate.HandleAsync(context);
Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}");
Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Success);
}
[Fact]
public async Task HandlePasswordGrant_EmitsLockoutAuditEvent()
{
@@ -74,8 +171,10 @@ public class PasswordGrantHandlersTests
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new LockoutCredentialStore());
var clientStore = new StubClientStore(CreateClientDocument());
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance);
var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.Instance);
var authorityOptions = CreateAuthorityOptions();
var optionsAccessor = Options.Create(authorityOptions);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, optionsAccessor, NullLogger<ValidatePasswordGrantHandler>.Instance);
var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, optionsAccessor, NullLogger<HandlePasswordGrantHandler>.Instance);
var transaction = CreatePasswordTransaction("alice", "Locked!");
@@ -92,7 +191,9 @@ public class PasswordGrantHandlersTests
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore());
var clientStore = new StubClientStore(CreateClientDocument());
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance);
var authorityOptions = CreateAuthorityOptions();
var optionsAccessor = Options.Create(authorityOptions);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, optionsAccessor, NullLogger<ValidatePasswordGrantHandler>.Instance);
var transaction = CreatePasswordTransaction("alice", "Password1!");
transaction.Request?.SetParameter("unexpected_param", "value");
@@ -106,9 +207,72 @@ public class PasswordGrantHandlersTests
string.Equals(property.Value.Value, "unexpected_param", StringComparison.OrdinalIgnoreCase));
}
private static AuthorityIdentityProviderRegistry CreateRegistry(IUserCredentialStore store)
[Fact]
public async Task ValidatePasswordGrant_RejectsExceptionsApprove_WhenMfaRequiredAndProviderLacksSupport()
{
var plugin = new StubIdentityProviderPlugin("stub", store);
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore(), supportsMfa: false);
var clientStore = new StubClientStore(CreateClientDocument("exceptions:approve"));
var authorityOptions = CreateAuthorityOptions(opts =>
{
opts.Exceptions.RoutingTemplates.Add(new AuthorityExceptionRoutingTemplateOptions
{
Id = "secops",
AuthorityRouteId = "approvals/secops",
RequireMfa = true
});
});
var optionsAccessor = Options.Create(authorityOptions);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, optionsAccessor, NullLogger<ValidatePasswordGrantHandler>.Instance);
var transaction = CreatePasswordTransaction("alice", "Password1!", "exceptions:approve");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await validate.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidScope, context.Error);
Assert.Equal("Exception approval scope requires an MFA-capable identity provider.", context.ErrorDescription);
Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Failure);
}
[Fact]
public async Task HandlePasswordGrant_AllowsExceptionsApprove_WhenMfaSupported()
{
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore(), supportsMfa: true);
var clientStore = new StubClientStore(CreateClientDocument("exceptions:approve"));
var authorityOptions = CreateAuthorityOptions(opts =>
{
opts.Exceptions.RoutingTemplates.Add(new AuthorityExceptionRoutingTemplateOptions
{
Id = "secops",
AuthorityRouteId = "approvals/secops",
RequireMfa = true
});
});
var optionsAccessor = Options.Create(authorityOptions);
var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, optionsAccessor, NullLogger<ValidatePasswordGrantHandler>.Instance);
var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, optionsAccessor, NullLogger<HandlePasswordGrantHandler>.Instance);
var transaction = CreatePasswordTransaction("alice", "Password1!", "exceptions:approve");
var validateContext = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await validate.HandleAsync(validateContext);
Assert.False(validateContext.IsRejected);
var handleContext = new OpenIddictServerEvents.HandleTokenRequestContext(transaction);
await handle.HandleAsync(handleContext);
Assert.False(handleContext.IsRejected);
Assert.NotNull(handleContext.Principal);
Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Success);
}
private static AuthorityIdentityProviderRegistry CreateRegistry(IUserCredentialStore store, bool supportsMfa = false)
{
var plugin = new StubIdentityProviderPlugin("stub", store, supportsMfa);
var services = new ServiceCollection();
services.AddLogging();
@@ -118,7 +282,7 @@ public class PasswordGrantHandlersTests
return new AuthorityIdentityProviderRegistry(provider, NullLogger<AuthorityIdentityProviderRegistry>.Instance);
}
private static OpenIddictServerTransaction CreatePasswordTransaction(string username, string password)
private static OpenIddictServerTransaction CreatePasswordTransaction(string username, string password, string scope = "jobs:trigger")
{
var request = new OpenIddictRequest
{
@@ -126,7 +290,7 @@ public class PasswordGrantHandlersTests
Username = username,
Password = password,
ClientId = "cli-app",
Scope = "jobs:trigger"
Scope = scope
};
return new OpenIddictServerTransaction
@@ -137,7 +301,21 @@ public class PasswordGrantHandlersTests
};
}
private static AuthorityClientDocument CreateClientDocument()
private static StellaOpsAuthorityOptions CreateAuthorityOptions(Action<StellaOpsAuthorityOptions>? configure = null)
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.test")
};
options.Signing.ActiveKeyId = "test-key";
options.Signing.KeyPath = "/tmp/test-key.pem";
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
configure?.Invoke(options);
return options;
}
private static AuthorityClientDocument CreateClientDocument(string allowedScopes = "jobs:trigger")
{
var document = new AuthorityClientDocument
{
@@ -146,7 +324,7 @@ public class PasswordGrantHandlersTests
};
document.Properties[AuthorityClientMetadataKeys.AllowedGrantTypes] = "password";
document.Properties[AuthorityClientMetadataKeys.AllowedScopes] = "jobs:trigger";
document.Properties[AuthorityClientMetadataKeys.AllowedScopes] = allowedScopes;
document.Properties[AuthorityClientMetadataKeys.Tenant] = "tenant-alpha";
return document;
@@ -154,23 +332,26 @@ public class PasswordGrantHandlersTests
private sealed class StubIdentityProviderPlugin : IIdentityProviderPlugin
{
public StubIdentityProviderPlugin(string name, IUserCredentialStore store)
public StubIdentityProviderPlugin(string name, IUserCredentialStore store, bool supportsMfa)
{
Name = name;
Type = "stub";
var capabilities = supportsMfa
? new[] { AuthorityPluginCapabilities.Password, AuthorityPluginCapabilities.Mfa }
: new[] { AuthorityPluginCapabilities.Password };
var manifest = new AuthorityPluginManifest(
Name: name,
Type: "stub",
Enabled: true,
AssemblyName: null,
AssemblyPath: null,
Capabilities: new[] { AuthorityPluginCapabilities.Password },
Capabilities: capabilities,
Metadata: new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase),
ConfigPath: $"{name}.yaml");
Context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build());
Credentials = store;
ClaimsEnricher = new NoopClaimsEnricher();
Capabilities = new AuthorityIdentityProviderCapabilities(SupportsPassword: true, SupportsMfa: false, SupportsClientProvisioning: false);
Capabilities = new AuthorityIdentityProviderCapabilities(SupportsPassword: true, SupportsMfa: supportsMfa, SupportsClientProvisioning: false);
}
public string Name { get; }
@@ -235,12 +416,10 @@ public class PasswordGrantHandlersTests
return ValueTask.FromResult(AuthorityCredentialVerificationResult.Failure(
AuthorityCredentialFailureCode.LockedOut,
"Account locked.",
retryAfter: retry,
auditProperties: properties));
retry,
properties));
}
private static readonly TimeProvider timeProvider = TimeProvider.System;
public ValueTask<AuthorityPluginOperationResult<AuthorityUserDescriptor>> UpsertUserAsync(AuthorityUserRegistration registration, CancellationToken cancellationToken)
=> throw new NotImplementedException();
@@ -250,24 +429,88 @@ public class PasswordGrantHandlersTests
private sealed class StubClientStore : IAuthorityClientStore
{
private readonly Dictionary<string, AuthorityClientDocument> clients;
private readonly AuthorityClientDocument document;
public StubClientStore(params AuthorityClientDocument[] documents)
public StubClientStore(AuthorityClientDocument document)
{
clients = documents.ToDictionary(static doc => doc.ClientId, doc => doc, StringComparer.OrdinalIgnoreCase);
this.document = document;
}
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
clients.TryGetValue(clientId, out var document);
return ValueTask.FromResult(document);
}
public Task<IReadOnlyList<AuthorityClientDocument>> ListAsync(CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<AuthorityClientDocument>>(new[] { document });
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
public Task<AuthorityClientDocument?> FindAsync(string id, CancellationToken cancellationToken)
=> Task.FromResult<AuthorityClientDocument?>(id == document.Id ? document : null);
public Task<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken)
=> Task.FromResult<AuthorityClientDocument?>(string.Equals(clientId, document.ClientId, StringComparison.Ordinal) ? document : null);
public Task<string> InsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken)
=> throw new NotImplementedException();
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
public Task UpdateAsync(string id, UpdateDefinition<AuthorityClientDocument> update, CancellationToken cancellationToken)
=> throw new NotImplementedException();
public Task<bool> DeleteAsync(string id, CancellationToken cancellationToken)
=> throw new NotImplementedException();
public Task<bool> ExistsAsync(string id, CancellationToken cancellationToken)
=> throw new NotImplementedException();
}
private sealed class TestAuthEventSink : IAuthEventSink
{
public List<AuthEventRecord> Events { get; } = new();
public Task WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
{
Events.Add(record);
return Task.CompletedTask;
}
}
private sealed class TestRateLimiterMetadataAccessor : IAuthorityRateLimiterMetadataAccessor
{
private AuthorityRateLimiterMetadata? metadata;
public AuthorityRateLimiterMetadata? GetMetadata() => metadata;
public void SetClientId(string? clientId)
{
metadata ??= new AuthorityRateLimiterMetadata();
metadata.ClientId = clientId;
}
public void SetTenant(string? tenant)
{
metadata ??= new AuthorityRateLimiterMetadata();
metadata.Tenant = tenant;
}
public void SetProject(string? project)
{
metadata ??= new AuthorityRateLimiterMetadata();
metadata.Project = project;
}
public void Clear()
{
metadata = null;
}
}
private sealed class SuccessCredentialStore : IUserCredentialStore
{
public ValueTask<AuthorityCredentialVerificationResult> VerifyPasswordAsync(string username, string password, CancellationToken cancellationToken)
{
var descriptor = new AuthorityUserDescriptor("subject", username, "User", requiresPasswordReset: false);
return ValueTask.FromResult(AuthorityCredentialVerificationResult.Success(descriptor));
}
public ValueTask<AuthorityPluginOperationResult<AuthorityUserDescriptor>> UpsertUserAsync(AuthorityUserRegistration registration, CancellationToken cancellationToken)
=> throw new NotImplementedException();
public ValueTask<AuthorityUserDescriptor?> FindBySubjectAsync(string subjectId, CancellationToken cancellationToken)
=> ValueTask.FromResult<AuthorityUserDescriptor?>(null);
}
}

View File

@@ -18,6 +18,7 @@ using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Extensions;
using StellaOps.Authority.Storage.Mongo.Initialization;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Auth.Abstractions;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Concelier.Testing;
using StellaOps.Authority.RateLimiting;
@@ -85,6 +86,7 @@ public sealed class TokenPersistenceIntegrationTests
var tokenId = principal.GetClaim(OpenIddictConstants.Claims.JwtId);
Assert.False(string.IsNullOrWhiteSpace(tokenId));
Assert.Equal("tenant-alpha", metadataAccessor.GetMetadata()?.Tenant);
Assert.Equal(StellaOpsTenancyDefaults.AnyProject, metadataAccessor.GetMetadata()?.Project);
var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction)
{
@@ -103,6 +105,7 @@ public sealed class TokenPersistenceIntegrationTests
Assert.Equal(issuedAt.AddMinutes(15), stored.ExpiresAt);
Assert.Equal(new[] { "jobs:trigger" }, stored.Scope);
Assert.Equal("tenant-alpha", stored.Tenant);
Assert.Equal(StellaOpsTenancyDefaults.AnyProject, stored.Project);
}
[Fact]

View File

@@ -10,6 +10,7 @@ using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Authority.Permalinks;
using StellaOps.Authority.Signing;

View File

@@ -19,6 +19,7 @@ public class AuthorityRateLimiterMetadataAccessorTests
accessor.SetTag("custom", "tag");
accessor.SetSubjectId("subject-1");
accessor.SetTenant("Tenant-Alpha");
accessor.SetProject("Project-Beta");
var metadata = accessor.GetMetadata();
Assert.NotNull(metadata);
@@ -28,6 +29,8 @@ public class AuthorityRateLimiterMetadataAccessorTests
Assert.Equal("subject-1", metadata.Tags["authority.subject_id"]);
Assert.Equal("tenant-alpha", metadata.Tenant);
Assert.Equal("tenant-alpha", metadata.Tags["authority.tenant"]);
Assert.Equal("project-beta", metadata.Project);
Assert.Equal("project-beta", metadata.Tags["authority.project"]);
Assert.Equal("tag", metadata.Tags["custom"]);
}
}

View File

@@ -0,0 +1,13 @@
using System;
using System.Runtime.CompilerServices;
internal static class TestEnvironment
{
[ModuleInitializer]
public static void Initialize()
{
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_ISSUER", "https://authority.test");
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_STORAGE__CONNECTIONSTRING", "mongodb://localhost/authority");
Environment.SetEnvironmentVariable("STELLAOPS_AUTHORITY_SIGNING__ENABLED", "false");
}
}

View File

@@ -0,0 +1,7 @@
namespace StellaOps.Authority;
internal static class AuthorityHttpHeaders
{
public const string Tenant = "X-StellaOps-Tenant";
public const string Project = "X-StellaOps-Project";
}

View File

@@ -0,0 +1,547 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Net;
using System.Security.Claims;
using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;
using OpenIddict.Abstractions;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Cryptography.Audit;
using StellaOps.Authority.Tenants;
namespace StellaOps.Authority.Console;
internal static class ConsoleEndpointExtensions
{
public static void MapConsoleEndpoints(this WebApplication app)
{
ArgumentNullException.ThrowIfNull(app);
var group = app.MapGroup("/console")
.RequireAuthorization()
.WithTags("Console");
group.AddEndpointFilter(new TenantHeaderFilter());
group.MapGet("/tenants", GetTenants)
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.AuthorityTenantsRead))
.WithName("ConsoleTenants")
.WithSummary("List the tenant metadata for the authenticated principal.");
group.MapGet("/profile", GetProfile)
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.UiRead))
.WithName("ConsoleProfile")
.WithSummary("Return the authenticated principal profile metadata.");
group.MapPost("/token/introspect", IntrospectToken)
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.UiRead))
.WithName("ConsoleTokenIntrospect")
.WithSummary("Introspect the current access token and return expiry, scope, and tenant metadata.");
}
private static async Task<IResult> GetTenants(
HttpContext httpContext,
IAuthorityTenantCatalog tenantCatalog,
IAuthEventSink auditSink,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(httpContext);
ArgumentNullException.ThrowIfNull(tenantCatalog);
ArgumentNullException.ThrowIfNull(auditSink);
ArgumentNullException.ThrowIfNull(timeProvider);
var normalizedTenant = TenantHeaderFilter.GetTenant(httpContext);
if (string.IsNullOrWhiteSpace(normalizedTenant))
{
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.tenants.read",
AuthEventOutcome.Failure,
"tenant_header_missing",
BuildProperties(("tenant.header", null)),
cancellationToken).ConfigureAwait(false);
return Results.BadRequest(new { error = "tenant_header_missing", message = $"Header '{AuthorityHttpHeaders.Tenant}' is required." });
}
var tenants = tenantCatalog.GetTenants();
var selected = tenants.FirstOrDefault(tenant =>
string.Equals(tenant.Id, normalizedTenant, StringComparison.Ordinal));
if (selected is null)
{
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.tenants.read",
AuthEventOutcome.Failure,
"tenant_not_configured",
BuildProperties(("tenant.requested", normalizedTenant)),
cancellationToken).ConfigureAwait(false);
return Results.NotFound(new { error = "tenant_not_configured", message = $"Tenant '{normalizedTenant}' is not configured." });
}
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.tenants.read",
AuthEventOutcome.Success,
null,
BuildProperties(("tenant.resolved", selected.Id)),
cancellationToken).ConfigureAwait(false);
var response = new TenantCatalogResponse(new[] { selected });
return Results.Ok(response);
}
private static async Task<IResult> GetProfile(
HttpContext httpContext,
TimeProvider timeProvider,
IAuthEventSink auditSink,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(httpContext);
ArgumentNullException.ThrowIfNull(timeProvider);
ArgumentNullException.ThrowIfNull(auditSink);
var principal = httpContext.User;
if (principal?.Identity?.IsAuthenticated is not true)
{
return Results.Unauthorized();
}
var profile = BuildProfile(principal, timeProvider);
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.profile.read",
AuthEventOutcome.Success,
null,
BuildProperties(("tenant.resolved", profile.Tenant)),
cancellationToken).ConfigureAwait(false);
return Results.Ok(profile);
}
private static async Task<IResult> IntrospectToken(
HttpContext httpContext,
TimeProvider timeProvider,
IAuthEventSink auditSink,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(httpContext);
ArgumentNullException.ThrowIfNull(timeProvider);
ArgumentNullException.ThrowIfNull(auditSink);
var principal = httpContext.User;
if (principal?.Identity?.IsAuthenticated is not true)
{
return Results.Unauthorized();
}
var introspection = BuildTokenIntrospection(principal, timeProvider);
await WriteAuditAsync(
httpContext,
auditSink,
timeProvider,
"authority.console.token.introspect",
AuthEventOutcome.Success,
null,
BuildProperties(
("token.active", introspection.Active ? "true" : "false"),
("token.expires_at", FormatInstant(introspection.ExpiresAt)),
("tenant.resolved", introspection.Tenant)),
cancellationToken).ConfigureAwait(false);
return Results.Ok(introspection);
}
private static ConsoleProfileResponse BuildProfile(ClaimsPrincipal principal, TimeProvider timeProvider)
{
var tenant = Normalize(principal.FindFirstValue(StellaOpsClaimTypes.Tenant)) ?? string.Empty;
var subject = Normalize(principal.FindFirstValue(StellaOpsClaimTypes.Subject));
var username = Normalize(principal.FindFirstValue(OpenIddictConstants.Claims.PreferredUsername));
var displayName = Normalize(principal.FindFirstValue(OpenIddictConstants.Claims.Name));
var sessionId = Normalize(principal.FindFirstValue(StellaOpsClaimTypes.SessionId));
var audiences = ExtractAudiences(principal);
var authenticationMethods = ExtractAuthenticationMethods(principal);
var roles = ExtractRoles(principal);
var scopes = ExtractScopes(principal);
var issuedAt = ExtractInstant(principal, OpenIddictConstants.Claims.IssuedAt, "iat");
var authTime = ExtractInstant(principal, OpenIddictConstants.Claims.AuthenticationTime, "auth_time");
var expiresAt = ExtractInstant(principal, OpenIddictConstants.Claims.ExpiresAt, "exp");
var now = timeProvider.GetUtcNow();
var freshAuth = DetermineFreshAuth(principal, now);
return new ConsoleProfileResponse(
SubjectId: subject,
Username: username,
DisplayName: displayName,
Tenant: tenant,
SessionId: sessionId,
Roles: roles,
Scopes: scopes,
Audiences: audiences,
AuthenticationMethods: authenticationMethods,
IssuedAt: issuedAt,
AuthenticationTime: authTime,
ExpiresAt: expiresAt,
FreshAuth: freshAuth);
}
private static ConsoleTokenIntrospectionResponse BuildTokenIntrospection(ClaimsPrincipal principal, TimeProvider timeProvider)
{
var now = timeProvider.GetUtcNow();
var expiresAt = ExtractInstant(principal, OpenIddictConstants.Claims.ExpiresAt, "exp");
var issuedAt = ExtractInstant(principal, OpenIddictConstants.Claims.IssuedAt, "iat");
var authTime = ExtractInstant(principal, OpenIddictConstants.Claims.AuthenticationTime, "auth_time");
var scopes = ExtractScopes(principal);
var audiences = ExtractAudiences(principal);
var tenant = Normalize(principal.FindFirstValue(StellaOpsClaimTypes.Tenant)) ?? string.Empty;
var subject = Normalize(principal.FindFirstValue(StellaOpsClaimTypes.Subject));
var tokenId = Normalize(principal.FindFirstValue(StellaOpsClaimTypes.TokenId));
var clientId = Normalize(principal.FindFirstValue(StellaOpsClaimTypes.ClientId));
var active = expiresAt is null || expiresAt > now;
var freshAuth = DetermineFreshAuth(principal, now);
return new ConsoleTokenIntrospectionResponse(
Active: active,
Tenant: tenant,
Subject: subject,
ClientId: clientId,
TokenId: tokenId,
Scopes: scopes,
Audiences: audiences,
IssuedAt: issuedAt,
AuthenticationTime: authTime,
ExpiresAt: expiresAt,
FreshAuth: freshAuth);
}
private static bool DetermineFreshAuth(ClaimsPrincipal principal, DateTimeOffset now)
{
var flag = principal.FindFirst("stellaops:fresh_auth") ?? principal.FindFirst("fresh_auth");
if (flag is not null && bool.TryParse(flag.Value, out var freshFlag))
{
if (freshFlag)
{
return true;
}
}
var authTime = ExtractInstant(principal, OpenIddictConstants.Claims.AuthenticationTime, "auth_time");
if (authTime is null)
{
return false;
}
var ttlClaim = principal.FindFirst("stellaops:fresh_auth_ttl");
if (ttlClaim is not null && TimeSpan.TryParse(ttlClaim.Value, CultureInfo.InvariantCulture, out var ttl))
{
return authTime.Value.Add(ttl) > now;
}
const int defaultFreshAuthWindowSeconds = 300;
return authTime.Value.AddSeconds(defaultFreshAuthWindowSeconds) > now;
}
private static IReadOnlyList<string> ExtractRoles(ClaimsPrincipal principal)
{
var roles = principal.FindAll(OpenIddictConstants.Claims.Role)
.Select(static claim => Normalize(claim.Value))
.Where(static value => value is not null)
.Select(static value => value!)
.Distinct(StringComparer.Ordinal)
.OrderBy(static value => value, StringComparer.Ordinal)
.ToArray();
return roles.Length == 0 ? Array.Empty<string>() : roles;
}
private static IReadOnlyList<string> ExtractScopes(ClaimsPrincipal principal)
{
var set = new HashSet<string>(StringComparer.Ordinal);
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.ScopeItem))
{
var normalized = Normalize(claim.Value);
if (normalized is not null)
{
set.Add(normalized);
}
}
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 (normalized is not null)
{
set.Add(normalized);
}
}
}
if (set.Count == 0)
{
return Array.Empty<string>();
}
return set.OrderBy(static value => value, StringComparer.Ordinal).ToArray();
}
private static IReadOnlyList<string> ExtractAudiences(ClaimsPrincipal principal)
{
var audiences = new HashSet<string>(StringComparer.Ordinal);
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.Audience))
{
if (string.IsNullOrWhiteSpace(claim.Value))
{
continue;
}
var parts = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var part in parts)
{
audiences.Add(part);
}
}
if (audiences.Count == 0)
{
return Array.Empty<string>();
}
return audiences.OrderBy(static value => value, StringComparer.Ordinal).ToArray();
}
private static IReadOnlyList<string> ExtractAuthenticationMethods(ClaimsPrincipal principal)
{
var methods = principal.FindAll(StellaOpsClaimTypes.AuthenticationMethod)
.Select(static claim => Normalize(claim.Value))
.Where(static value => value is not null)
.Select(static value => value!)
.Distinct(StringComparer.Ordinal)
.OrderBy(static value => value, StringComparer.Ordinal)
.ToArray();
return methods.Length == 0 ? Array.Empty<string>() : methods;
}
private static DateTimeOffset? ExtractInstant(ClaimsPrincipal principal, string primaryClaim, string fallbackClaim)
{
var claim = principal.FindFirst(primaryClaim) ?? principal.FindFirst(fallbackClaim);
if (claim is null || string.IsNullOrWhiteSpace(claim.Value))
{
return null;
}
if (long.TryParse(claim.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var epoch))
{
return DateTimeOffset.FromUnixTimeSeconds(epoch);
}
if (DateTimeOffset.TryParse(claim.Value, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var parsed))
{
return parsed;
}
return null;
}
private static async Task WriteAuditAsync(
HttpContext httpContext,
IAuthEventSink auditSink,
TimeProvider timeProvider,
string eventType,
AuthEventOutcome outcome,
string? reason,
IReadOnlyList<AuthEventProperty> properties,
CancellationToken cancellationToken)
{
var correlationId = Activity.Current?.TraceId.ToString() ?? httpContext.TraceIdentifier;
var tenant = Normalize(httpContext.User.FindFirstValue(StellaOpsClaimTypes.Tenant));
var subjectId = Normalize(httpContext.User.FindFirstValue(StellaOpsClaimTypes.Subject));
var username = Normalize(httpContext.User.FindFirstValue(OpenIddictConstants.Claims.PreferredUsername));
var displayName = Normalize(httpContext.User.FindFirstValue(OpenIddictConstants.Claims.Name));
var identityProvider = Normalize(httpContext.User.FindFirstValue(StellaOpsClaimTypes.IdentityProvider));
var email = Normalize(httpContext.User.FindFirstValue(OpenIddictConstants.Claims.Email));
var subjectProperties = new List<AuthEventProperty>();
if (!string.IsNullOrWhiteSpace(email))
{
subjectProperties.Add(new AuthEventProperty
{
Name = "subject.email",
Value = ClassifiedString.Personal(email)
});
}
var subject = subjectId is null && username is null && displayName is null && identityProvider is null && subjectProperties.Count == 0
? null
: new AuthEventSubject
{
SubjectId = ClassifiedString.Personal(subjectId),
Username = ClassifiedString.Personal(username),
DisplayName = ClassifiedString.Personal(displayName),
Realm = ClassifiedString.Public(identityProvider),
Attributes = subjectProperties
};
var clientId = Normalize(httpContext.User.FindFirstValue(StellaOpsClaimTypes.ClientId));
var client = string.IsNullOrWhiteSpace(clientId)
? null
: new AuthEventClient
{
ClientId = ClassifiedString.Personal(clientId),
Name = ClassifiedString.Empty,
Provider = ClassifiedString.Empty
};
var network = BuildNetwork(httpContext);
var scopes = ExtractScopes(httpContext.User);
var record = new AuthEventRecord
{
EventType = eventType,
OccurredAt = timeProvider.GetUtcNow(),
CorrelationId = correlationId,
Outcome = outcome,
Reason = reason,
Subject = subject,
Client = client,
Tenant = ClassifiedString.Public(tenant),
Scopes = scopes,
Network = network,
Properties = properties
};
await auditSink.WriteAsync(record, cancellationToken).ConfigureAwait(false);
}
private static AuthEventNetwork? BuildNetwork(HttpContext httpContext)
{
var remote = httpContext.Connection.RemoteIpAddress;
var remoteAddress = remote is null || Equals(remote, IPAddress.IPv6None) || Equals(remote, IPAddress.None)
? null
: remote.ToString();
var forwarded = Normalize(httpContext.Request.Headers[XForwardedForHeader]);
var userAgent = Normalize(httpContext.Request.Headers.UserAgent.ToString());
if (string.IsNullOrWhiteSpace(remoteAddress) &&
string.IsNullOrWhiteSpace(forwarded) &&
string.IsNullOrWhiteSpace(userAgent))
{
return null;
}
return new AuthEventNetwork
{
RemoteAddress = ClassifiedString.Personal(remoteAddress),
ForwardedFor = ClassifiedString.Personal(forwarded),
UserAgent = ClassifiedString.Personal(userAgent)
};
}
private static IReadOnlyList<AuthEventProperty> BuildProperties(params (string Name, string? Value)[] entries)
{
if (entries.Length == 0)
{
return Array.Empty<AuthEventProperty>();
}
var list = new List<AuthEventProperty>(entries.Length);
foreach (var (name, value) in entries)
{
if (string.IsNullOrWhiteSpace(name))
{
continue;
}
list.Add(new AuthEventProperty
{
Name = name,
Value = string.IsNullOrWhiteSpace(value)
? ClassifiedString.Empty
: ClassifiedString.Public(value)
});
}
return list.Count == 0 ? Array.Empty<AuthEventProperty>() : list;
}
private static string? Normalize(StringValues values)
{
var value = values.ToString();
return Normalize(value);
}
private static string? Normalize(string? input)
{
if (string.IsNullOrWhiteSpace(input))
{
return null;
}
return input.Trim();
}
private static string? FormatInstant(DateTimeOffset? instant)
{
return instant?.ToString("O", CultureInfo.InvariantCulture);
}
private const string XForwardedForHeader = "X-Forwarded-For";
}
internal sealed record TenantCatalogResponse(IReadOnlyList<AuthorityTenantView> Tenants);
internal sealed record ConsoleProfileResponse(
string? SubjectId,
string? Username,
string? DisplayName,
string Tenant,
string? SessionId,
IReadOnlyList<string> Roles,
IReadOnlyList<string> Scopes,
IReadOnlyList<string> Audiences,
IReadOnlyList<string> AuthenticationMethods,
DateTimeOffset? IssuedAt,
DateTimeOffset? AuthenticationTime,
DateTimeOffset? ExpiresAt,
bool FreshAuth);
internal sealed record ConsoleTokenIntrospectionResponse(
bool Active,
string Tenant,
string? Subject,
string? ClientId,
string? TokenId,
IReadOnlyList<string> Scopes,
IReadOnlyList<string> Audiences,
DateTimeOffset? IssuedAt,
DateTimeOffset? AuthenticationTime,
DateTimeOffset? ExpiresAt,
bool FreshAuth);

View File

@@ -0,0 +1,75 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Authority.Console;
internal sealed class TenantHeaderFilter : IEndpointFilter
{
private const string TenantItemKey = "__authority-console-tenant";
public ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(next);
var httpContext = context.HttpContext;
var principal = httpContext.User;
if (principal?.Identity?.IsAuthenticated is not true)
{
return ValueTask.FromResult<object?>(Results.Unauthorized());
}
var tenantHeader = httpContext.Request.Headers[AuthorityHttpHeaders.Tenant];
if (IsMissing(tenantHeader))
{
return ValueTask.FromResult<object?>(Results.BadRequest(new
{
error = "tenant_header_missing",
message = $"Header '{AuthorityHttpHeaders.Tenant}' is required."
}));
}
var normalizedHeader = tenantHeader.ToString().Trim().ToLowerInvariant();
var claimTenant = principal.FindFirstValue(StellaOpsClaimTypes.Tenant);
if (string.IsNullOrWhiteSpace(claimTenant))
{
return ValueTask.FromResult<object?>(Results.Forbid());
}
var normalizedClaim = claimTenant.Trim().ToLowerInvariant();
if (!string.Equals(normalizedClaim, normalizedHeader, StringComparison.Ordinal))
{
return ValueTask.FromResult<object?>(Results.Forbid());
}
httpContext.Items[TenantItemKey] = normalizedHeader;
return next(context);
}
internal static string? GetTenant(HttpContext httpContext)
{
ArgumentNullException.ThrowIfNull(httpContext);
if (httpContext.Items.TryGetValue(TenantItemKey, out var value) && value is string tenant && !string.IsNullOrWhiteSpace(tenant))
{
return tenant;
}
return null;
}
private static bool IsMissing(StringValues values)
{
if (StringValues.IsNullOrEmpty(values))
{
return true;
}
var value = values.ToString();
return string.IsNullOrWhiteSpace(value);
}
}

View File

@@ -0,0 +1,314 @@
using System.Collections.Generic;
using System.IO;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using YamlDotNet.Core;
using YamlDotNet.RepresentationModel;
using YamlDotNet.Serialization;
namespace StellaOps.Authority.OpenApi;
internal sealed class AuthorityOpenApiDocumentProvider
{
private readonly string specificationPath;
private readonly ILogger<AuthorityOpenApiDocumentProvider> logger;
private readonly SemaphoreSlim refreshLock = new(1, 1);
private OpenApiDocumentSnapshot? cached;
public AuthorityOpenApiDocumentProvider(IWebHostEnvironment environment, ILogger<AuthorityOpenApiDocumentProvider> logger)
{
ArgumentNullException.ThrowIfNull(environment);
ArgumentNullException.ThrowIfNull(logger);
specificationPath = Path.Combine(environment.ContentRootPath, "OpenApi", "authority.yaml");
this.logger = logger;
}
public async ValueTask<OpenApiDocumentSnapshot> GetDocumentAsync(CancellationToken cancellationToken)
{
var lastWriteUtc = GetLastWriteTimeUtc();
var current = cached;
if (current is not null && current.LastWriteUtc == lastWriteUtc)
{
return current;
}
await refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
current = cached;
lastWriteUtc = GetLastWriteTimeUtc();
if (current is not null && current.LastWriteUtc == lastWriteUtc)
{
return current;
}
var snapshot = LoadSnapshot(lastWriteUtc);
cached = snapshot;
return snapshot;
}
finally
{
refreshLock.Release();
}
}
private DateTime GetLastWriteTimeUtc()
{
var file = new FileInfo(specificationPath);
if (!file.Exists)
{
throw new FileNotFoundException($"Authority OpenAPI specification was not found at '{specificationPath}'.", specificationPath);
}
return file.LastWriteTimeUtc;
}
private OpenApiDocumentSnapshot LoadSnapshot(DateTime lastWriteUtc)
{
string yamlText;
try
{
yamlText = File.ReadAllText(specificationPath);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to read Authority OpenAPI specification from {Path}.", specificationPath);
throw;
}
var yamlStream = new YamlStream();
using (var reader = new StringReader(yamlText))
{
yamlStream.Load(reader);
}
if (yamlStream.Documents.Count == 0 || yamlStream.Documents[0].RootNode is not YamlMappingNode rootNode)
{
throw new InvalidOperationException("Authority OpenAPI specification does not contain a valid root mapping node.");
}
var (grants, scopes) = CollectGrantsAndScopes(rootNode);
if (!TryGetMapping(rootNode, "info", out var infoNode))
{
infoNode = new YamlMappingNode();
rootNode.Children[new YamlScalarNode("info")] = infoNode;
}
var serviceName = "StellaOps.Authority";
var buildVersion = ResolveBuildVersion();
ApplyInfoMetadata(infoNode, serviceName, buildVersion, grants, scopes);
var apiVersion = TryGetScalar(infoNode, "version", out var version)
? version
: "0.0.0";
var updatedYaml = WriteYaml(yamlStream);
var json = ConvertYamlToJson(updatedYaml);
var etag = CreateStrongEtag(json);
return new OpenApiDocumentSnapshot(
serviceName,
apiVersion,
buildVersion,
json,
updatedYaml,
etag,
lastWriteUtc,
grants,
scopes);
}
private static (IReadOnlyList<string> Grants, IReadOnlyList<string> Scopes) CollectGrantsAndScopes(YamlMappingNode root)
{
if (!TryGetMapping(root, "components", out var components) ||
!TryGetMapping(components, "securitySchemes", out var securitySchemes))
{
return (Array.Empty<string>(), Array.Empty<string>());
}
var grants = new SortedSet<string>(StringComparer.Ordinal);
var scopes = new SortedSet<string>(StringComparer.Ordinal);
foreach (var scheme in securitySchemes.Children.Values.OfType<YamlMappingNode>())
{
if (!TryGetMapping(scheme, "flows", out var flows))
{
continue;
}
foreach (var flowEntry in flows.Children)
{
if (flowEntry.Key is not YamlScalarNode flowNameNode || flowEntry.Value is not YamlMappingNode flowMapping)
{
continue;
}
var grant = NormalizeGrantName(flowNameNode.Value);
if (grant is not null)
{
grants.Add(grant);
}
if (TryGetMapping(flowMapping, "scopes", out var scopesMapping))
{
foreach (var scope in scopesMapping.Children.Keys.OfType<YamlScalarNode>())
{
if (!string.IsNullOrWhiteSpace(scope.Value))
{
scopes.Add(scope.Value);
}
}
}
if (flowMapping.Children.TryGetValue(new YamlScalarNode("refreshUrl"), out var refreshNode) &&
refreshNode is YamlScalarNode refreshScalar && !string.IsNullOrWhiteSpace(refreshScalar.Value))
{
grants.Add("refresh_token");
}
}
}
return (
grants.Count == 0 ? Array.Empty<string>() : grants.ToArray(),
scopes.Count == 0 ? Array.Empty<string>() : scopes.ToArray());
}
private static string? NormalizeGrantName(string? flowName)
=> flowName switch
{
null or "" => null,
"authorizationCode" => "authorization_code",
"clientCredentials" => "client_credentials",
"password" => "password",
"implicit" => "implicit",
"deviceCode" => "device_code",
_ => flowName
};
private static void ApplyInfoMetadata(
YamlMappingNode infoNode,
string serviceName,
string buildVersion,
IReadOnlyList<string> grants,
IReadOnlyList<string> scopes)
{
infoNode.Children[new YamlScalarNode("x-stella-service")] = new YamlScalarNode(serviceName);
infoNode.Children[new YamlScalarNode("x-stella-build-version")] = new YamlScalarNode(buildVersion);
infoNode.Children[new YamlScalarNode("x-stella-grant-types")] = CreateSequence(grants);
infoNode.Children[new YamlScalarNode("x-stella-scopes")] = CreateSequence(scopes);
}
private static YamlSequenceNode CreateSequence(IEnumerable<string> values)
{
var sequence = new YamlSequenceNode();
foreach (var value in values)
{
sequence.Add(new YamlScalarNode(value));
}
return sequence;
}
private static bool TryGetMapping(YamlMappingNode node, string key, out YamlMappingNode mapping)
{
foreach (var entry in node.Children)
{
if (entry.Key is YamlScalarNode scalar && string.Equals(scalar.Value, key, StringComparison.Ordinal))
{
if (entry.Value is YamlMappingNode mappingNode)
{
mapping = mappingNode;
return true;
}
break;
}
}
mapping = null!;
return false;
}
private static bool TryGetScalar(YamlMappingNode node, string key, out string value)
{
foreach (var entry in node.Children)
{
if (entry.Key is YamlScalarNode scalar && string.Equals(scalar.Value, key, StringComparison.Ordinal))
{
if (entry.Value is YamlScalarNode valueNode)
{
value = valueNode.Value ?? string.Empty;
return true;
}
break;
}
}
value = string.Empty;
return false;
}
private static string WriteYaml(YamlStream yamlStream)
{
using var writer = new StringWriter(CultureInfo.InvariantCulture);
yamlStream.Save(writer, assignAnchors: false);
return writer.ToString();
}
private static string ConvertYamlToJson(string yaml)
{
var deserializer = new DeserializerBuilder().Build();
var yamlObject = deserializer.Deserialize(new StringReader(yaml));
var serializer = new SerializerBuilder()
.JsonCompatible()
.Build();
var json = serializer.Serialize(yamlObject);
return string.IsNullOrWhiteSpace(json) ? "{}" : json.Trim();
}
private static string CreateStrongEtag(string jsonRepresentation)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(jsonRepresentation));
var hash = Convert.ToHexString(bytes).ToLowerInvariant();
return $"\"{hash}\"";
}
private static string ResolveBuildVersion()
{
var assembly = typeof(AuthorityOpenApiDocumentProvider).Assembly;
var informational = assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
.InformationalVersion;
if (!string.IsNullOrWhiteSpace(informational))
{
return informational!;
}
var version = assembly.GetName().Version;
return version?.ToString() ?? "unknown";
}
}
internal sealed record OpenApiDocumentSnapshot(
string ServiceName,
string ApiVersion,
string BuildVersion,
string Json,
string Yaml,
string ETag,
DateTime LastWriteUtc,
IReadOnlyList<string> GrantTypes,
IReadOnlyList<string> Scopes);

View File

@@ -0,0 +1,141 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Primitives;
using Microsoft.Net.Http.Headers;
namespace StellaOps.Authority.OpenApi;
internal static class OpenApiDiscoveryEndpointExtensions
{
private const string JsonMediaType = "application/openapi+json";
private const string YamlMediaType = "application/openapi+yaml";
private static readonly string[] AdditionalYamlMediaTypes = { "application/yaml", "text/yaml" };
private static readonly string[] AdditionalJsonMediaTypes = { "application/json" };
public static IEndpointConventionBuilder MapAuthorityOpenApiDiscovery(this IEndpointRouteBuilder endpoints)
{
ArgumentNullException.ThrowIfNull(endpoints);
var builder = endpoints.MapGet("/.well-known/openapi", async (HttpContext context, AuthorityOpenApiDocumentProvider provider, CancellationToken cancellationToken) =>
{
var snapshot = await provider.GetDocumentAsync(cancellationToken).ConfigureAwait(false);
var preferYaml = ShouldReturnYaml(context.Request.GetTypedHeaders().Accept);
var payload = preferYaml ? snapshot.Yaml : snapshot.Json;
var mediaType = preferYaml ? YamlMediaType : JsonMediaType;
ApplyMetadataHeaders(context.Response, snapshot);
if (MatchesEtag(context.Request.Headers[HeaderNames.IfNoneMatch], snapshot.ETag))
{
context.Response.StatusCode = StatusCodes.Status304NotModified;
return;
}
context.Response.StatusCode = StatusCodes.Status200OK;
context.Response.ContentType = mediaType;
await context.Response.WriteAsync(payload, cancellationToken).ConfigureAwait(false);
});
return builder.WithName("AuthorityOpenApiDiscovery");
}
private static bool ShouldReturnYaml(IList<MediaTypeHeaderValue>? accept)
{
if (accept is null || accept.Count == 0)
{
return false;
}
var ordered = accept
.OrderByDescending(value => value.Quality ?? 1.0)
.ThenByDescending(value => value.MediaType.HasValue && IsYaml(value.MediaType.Value));
foreach (var value in ordered)
{
if (!value.MediaType.HasValue)
{
continue;
}
var mediaType = value.MediaType.Value;
if (IsYaml(mediaType))
{
return true;
}
if (IsJson(mediaType) || mediaType.Equals("*/*", StringComparison.Ordinal))
{
return false;
}
}
return false;
}
private static bool IsYaml(string mediaType)
=> mediaType.Equals(YamlMediaType, StringComparison.OrdinalIgnoreCase)
|| AdditionalYamlMediaTypes.Any(candidate => candidate.Equals(mediaType, StringComparison.OrdinalIgnoreCase));
private static bool IsJson(string mediaType)
=> mediaType.Equals(JsonMediaType, StringComparison.OrdinalIgnoreCase)
|| AdditionalJsonMediaTypes.Any(candidate => candidate.Equals(mediaType, StringComparison.OrdinalIgnoreCase));
private static void ApplyMetadataHeaders(HttpResponse response, OpenApiDocumentSnapshot snapshot)
{
response.Headers[HeaderNames.ETag] = snapshot.ETag;
response.Headers[HeaderNames.LastModified] = snapshot.LastWriteUtc.ToString("R", CultureInfo.InvariantCulture);
response.Headers[HeaderNames.CacheControl] = "public, max-age=300";
response.Headers[HeaderNames.Vary] = "Accept";
response.Headers["X-StellaOps-Service"] = snapshot.ServiceName;
response.Headers["X-StellaOps-Api-Version"] = snapshot.ApiVersion;
response.Headers["X-StellaOps-Build-Version"] = snapshot.BuildVersion;
if (snapshot.GrantTypes.Count > 0)
{
response.Headers["X-StellaOps-OAuth-Grants"] = string.Join(' ', snapshot.GrantTypes);
}
if (snapshot.Scopes.Count > 0)
{
response.Headers["X-StellaOps-OAuth-Scopes"] = string.Join(' ', snapshot.Scopes);
}
}
private static bool MatchesEtag(StringValues etagValues, string currentEtag)
{
if (etagValues.Count == 0)
{
return false;
}
foreach (var value in etagValues)
{
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
var tokens = value.Split(',');
foreach (var token in tokens)
{
var trimmed = token.Trim();
if (trimmed.Length == 0)
{
continue;
}
if (trimmed.Equals("*", StringComparison.Ordinal) || trimmed.Equals(currentEtag, StringComparison.Ordinal))
{
return true;
}
}
}
return false;
}
}

View File

@@ -9,11 +9,11 @@ internal static class AuthorityOpenIddictConstants
internal const string ClientGrantedScopesProperty = "authority:client_granted_scopes";
internal const string TokenTransactionProperty = "authority:token";
internal const string AuditCorrelationProperty = "authority:audit_correlation_id";
internal const string AuditClientIdProperty = "authority:audit_client_id";
internal const string AuditProviderProperty = "authority:audit_provider";
internal const string AuditConfidentialProperty = "authority:audit_confidential";
internal const string AuditRequestedScopesProperty = "authority:audit_requested_scopes";
internal const string AuditGrantedScopesProperty = "authority:audit_granted_scopes";
internal const string AuditClientIdProperty = "authority:audit_client_id";
internal const string AuditProviderProperty = "authority:audit_provider";
internal const string AuditConfidentialProperty = "authority:audit_confidential";
internal const string AuditRequestedScopesProperty = "authority:audit_requested_scopes";
internal const string AuditGrantedScopesProperty = "authority:audit_granted_scopes";
internal const string AuditInvalidScopeProperty = "authority:audit_invalid_scope";
internal const string ClientSenderConstraintProperty = "authority:client_sender_constraint";
internal const string SenderConstraintProperty = "authority:sender_constraint";
@@ -26,4 +26,13 @@ internal static class AuthorityOpenIddictConstants
internal const string MtlsCertificateThumbprintProperty = "authority:mtls_thumbprint";
internal const string MtlsCertificateHexProperty = "authority:mtls_thumbprint_hex";
internal const string ClientTenantProperty = "authority:client_tenant";
internal const string ClientProjectProperty = "authority:client_project";
internal const string OperatorReasonProperty = "authority:operator_reason";
internal const string OperatorTicketProperty = "authority:operator_ticket";
internal const string OperatorReasonParameterName = "operator_reason";
internal const string OperatorTicketParameterName = "operator_ticket";
internal const string ExportAdminReasonProperty = "authority:export_admin_reason";
internal const string ExportAdminTicketProperty = "authority:export_admin_ticket";
internal const string ExportAdminReasonParameterName = "export_reason";
internal const string ExportAdminTicketParameterName = "export_ticket";
}

View File

@@ -7,6 +7,7 @@ using OpenIddict.Abstractions;
using OpenIddict.Server;
using StellaOps.Authority.RateLimiting;
using StellaOps.Cryptography.Audit;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Authority.OpenIddict.Handlers;
@@ -40,6 +41,7 @@ internal static class ClientCredentialsAuditHelper
string? clientId,
string? providerName,
string? tenant,
string? project,
bool? confidential,
IReadOnlyList<string> requestedScopes,
IReadOnlyList<string> grantedScopes,
@@ -56,6 +58,7 @@ internal static class ClientCredentialsAuditHelper
var normalizedGranted = NormalizeScopes(grantedScopes);
var properties = BuildProperties(confidential, requestedScopes, invalidScope, extraProperties);
var normalizedTenant = NormalizeTenant(tenant);
var normalizedProject = NormalizeProject(project);
return new AuthEventRecord
{
@@ -69,6 +72,7 @@ internal static class ClientCredentialsAuditHelper
Scopes = normalizedGranted,
Network = network,
Tenant = ClassifiedString.Public(normalizedTenant),
Project = ClassifiedString.Public(normalizedProject),
Properties = properties
};
}
@@ -80,6 +84,7 @@ internal static class ClientCredentialsAuditHelper
string? clientId,
string? providerName,
string? tenant,
string? project,
bool? confidential,
IEnumerable<string> unexpectedParameters)
{
@@ -132,6 +137,7 @@ internal static class ClientCredentialsAuditHelper
clientId: clientId,
providerName: providerName,
tenant: tenant,
project: project,
confidential: confidential,
requestedScopes: Array.Empty<string>(),
grantedScopes: Array.Empty<string>(),
@@ -257,4 +263,7 @@ internal static class ClientCredentialsAuditHelper
private static string? NormalizeTenant(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
private static string NormalizeProject(string? value)
=> string.IsNullOrWhiteSpace(value) ? StellaOpsTenancyDefaults.AnyProject : value.Trim().ToLowerInvariant();
}

View File

@@ -102,8 +102,9 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
clientId,
providerHint,
tenant: metadata?.Tenant,
project: metadata?.Project,
confidential: null,
unexpectedParameters);
unexpectedParameters: unexpectedParameters);
await auditSink.WriteAsync(tamperRecord, context.CancellationToken).ConfigureAwait(false);
}
@@ -249,8 +250,8 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
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;
}
}
var grantedScopes = resolvedScopes.Scopes;
bool EnsureTenantAssigned()
@@ -278,16 +279,62 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
return false;
}
static string? NormalizeMetadata(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
var hasGraphRead = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.GraphRead) >= 0;
var hasGraphWrite = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.GraphWrite) >= 0;
var hasGraphExport = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.GraphExport) >= 0;
var hasGraphSimulate = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.GraphSimulate) >= 0;
var graphScopesRequested = hasGraphRead || hasGraphWrite || hasGraphExport || hasGraphSimulate;
var hasOrchRead = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.OrchRead) >= 0;
var hasOrchOperate = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.OrchOperate) >= 0;
var hasExportViewer = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.ExportViewer) >= 0;
var hasExportOperator = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.ExportOperator) >= 0;
var hasExportAdmin = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.ExportAdmin) >= 0;
var exportScopesRequested = hasExportViewer || hasExportOperator || hasExportAdmin;
var hasAdvisoryIngest = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.AdvisoryIngest) >= 0;
var hasAdvisoryRead = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.AdvisoryRead) >= 0;
var hasVexIngest = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.VexIngest) >= 0;
var hasVexRead = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.VexRead) >= 0;
var hasVulnRead = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.VulnRead) >= 0;
var hasSignalsRead = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.SignalsRead) >= 0;
var hasSignalsWrite = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.SignalsWrite) >= 0;
var hasSignalsAdmin = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.SignalsAdmin) >= 0;
var signalsScopesRequested = hasSignalsRead || hasSignalsWrite || hasSignalsAdmin;
var hasPolicyAuthor = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyAuthor) >= 0;
var hasPolicyReview = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyReview) >= 0;
var hasPolicyOperate = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyOperate) >= 0;
var hasPolicyAudit = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyAudit) >= 0;
var hasPolicyApprove = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyApprove) >= 0;
var hasPolicyRun = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyRun) >= 0;
var hasPolicyActivate = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyActivate) >= 0;
var hasPolicySimulate = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicySimulate) >= 0;
var policyStudioScopesRequested = hasPolicyAuthor
|| hasPolicyReview
|| hasPolicyOperate
|| hasPolicyAudit
|| hasPolicyApprove
|| hasPolicyRun
|| hasPolicyActivate
|| hasPolicySimulate
|| grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.PolicyRead) >= 0;
var hasAocVerify = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.AocVerify) >= 0;
if (exportScopesRequested && !EnsureTenantAssigned())
{
var exportScopeForAudit = hasExportAdmin
? StellaOpsScopes.ExportAdmin
: hasExportOperator
? StellaOpsScopes.ExportOperator
: StellaOpsScopes.ExportViewer;
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = exportScopeForAudit;
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Export scopes require a tenant assignment.");
logger.LogWarning(
"Client credentials validation failed for {ClientId}: export scopes require tenant assignment.",
document.ClientId);
return;
}
var tenantScopeForAudit = hasGraphWrite
? StellaOpsScopes.GraphWrite
@@ -318,6 +365,97 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
return;
}
if ((hasOrchRead || hasOrchOperate) && !EnsureTenantAssigned())
{
var invalidScope = hasOrchOperate ? StellaOpsScopes.OrchOperate : StellaOpsScopes.OrchRead;
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = invalidScope;
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Orchestrator scopes require a tenant assignment.");
logger.LogWarning(
"Client credentials validation failed for {ClientId}: orchestrator scopes require tenant assignment.",
document.ClientId);
return;
}
if (hasOrchOperate)
{
var reasonRaw = context.Request.GetParameter(AuthorityOpenIddictConstants.OperatorReasonParameterName)?.Value?.ToString();
var reason = NormalizeMetadata(reasonRaw);
if (string.IsNullOrWhiteSpace(reason))
{
context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Operator actions require 'operator_reason'.");
logger.LogWarning("Client credentials validation failed for {ClientId}: operator_reason missing.", document.ClientId);
return;
}
if (reason.Length > 256)
{
context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Operator reason must not exceed 256 characters.");
logger.LogWarning("Client credentials validation failed for {ClientId}: operator_reason exceeded length limit.", document.ClientId);
return;
}
var ticketRaw = context.Request.GetParameter(AuthorityOpenIddictConstants.OperatorTicketParameterName)?.Value?.ToString();
var ticket = NormalizeMetadata(ticketRaw);
if (string.IsNullOrWhiteSpace(ticket))
{
context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Operator actions require 'operator_ticket'.");
logger.LogWarning("Client credentials validation failed for {ClientId}: operator_ticket missing.", document.ClientId);
return;
}
if (ticket.Length > 128)
{
context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Operator ticket must not exceed 128 characters.");
logger.LogWarning("Client credentials validation failed for {ClientId}: operator_ticket exceeded length limit.", document.ClientId);
return;
}
context.Transaction.Properties[AuthorityOpenIddictConstants.OperatorReasonProperty] = reason;
context.Transaction.Properties[AuthorityOpenIddictConstants.OperatorTicketProperty] = ticket;
activity?.SetTag("authority.operator_reason_present", true);
activity?.SetTag("authority.operator_ticket_present", true);
}
if (hasExportAdmin)
{
var reasonRaw = context.Request.GetParameter(AuthorityOpenIddictConstants.ExportAdminReasonParameterName)?.Value?.ToString();
var reason = NormalizeMetadata(reasonRaw);
if (string.IsNullOrWhiteSpace(reason))
{
context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Export admin actions require 'export_reason'.");
logger.LogWarning("Client credentials validation failed for {ClientId}: export_reason missing.", document.ClientId);
return;
}
if (reason.Length > 256)
{
context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Export admin reason must not exceed 256 characters.");
logger.LogWarning("Client credentials validation failed for {ClientId}: export_reason exceeded length limit.", document.ClientId);
return;
}
var ticketRaw = context.Request.GetParameter(AuthorityOpenIddictConstants.ExportAdminTicketParameterName)?.Value?.ToString();
var ticket = NormalizeMetadata(ticketRaw);
if (string.IsNullOrWhiteSpace(ticket))
{
context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Export admin actions require 'export_ticket'.");
logger.LogWarning("Client credentials validation failed for {ClientId}: export_ticket missing.", document.ClientId);
return;
}
if (ticket.Length > 128)
{
context.Reject(OpenIddictConstants.Errors.InvalidRequest, "Export admin ticket must not exceed 128 characters.");
logger.LogWarning("Client credentials validation failed for {ClientId}: export_ticket exceeded length limit.", document.ClientId);
return;
}
context.Transaction.Properties[AuthorityOpenIddictConstants.ExportAdminReasonProperty] = reason;
context.Transaction.Properties[AuthorityOpenIddictConstants.ExportAdminTicketProperty] = ticket;
activity?.SetTag("authority.export_admin_reason_present", true);
activity?.SetTag("authority.export_admin_ticket_present", true);
}
if ((hasVexIngest || hasVexRead) && !EnsureTenantAssigned())
{
var vexScope = hasVexIngest ? StellaOpsScopes.VexIngest : StellaOpsScopes.VexRead;
@@ -339,6 +477,59 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
return;
}
if ((hasAdvisoryRead || hasVexRead) && !hasAocVerify)
{
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = StellaOpsScopes.AocVerify;
activity?.SetTag("authority.aoc_scope_violation", "advisory_vex_requires_aoc");
context.Reject(OpenIddictConstants.Errors.InvalidScope, "Scope 'aoc:verify' is required when requesting advisory/vex read scopes.");
logger.LogWarning(
"Client credentials validation failed for {ClientId}: advisory/vex read scopes require aoc:verify.",
document.ClientId);
return;
}
if (signalsScopesRequested && !hasAocVerify)
{
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = StellaOpsScopes.AocVerify;
activity?.SetTag("authority.aoc_scope_violation", "signals_requires_aoc");
context.Reject(OpenIddictConstants.Errors.InvalidScope, "Scope 'aoc:verify' is required when requesting signals scopes.");
logger.LogWarning(
"Client credentials validation failed for {ClientId}: signals scopes require aoc:verify.",
document.ClientId);
return;
}
if (policyStudioScopesRequested && !EnsureTenantAssigned())
{
var policyScopeForAudit =
hasPolicyAuthor ? StellaOpsScopes.PolicyAuthor :
hasPolicyReview ? StellaOpsScopes.PolicyReview :
hasPolicyOperate ? StellaOpsScopes.PolicyOperate :
hasPolicyAudit ? StellaOpsScopes.PolicyAudit :
hasPolicyApprove ? StellaOpsScopes.PolicyApprove :
hasPolicyRun ? StellaOpsScopes.PolicyRun :
hasPolicyActivate ? StellaOpsScopes.PolicyActivate :
hasPolicySimulate ? StellaOpsScopes.PolicySimulate :
StellaOpsScopes.PolicyRead;
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = policyScopeForAudit;
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Policy Studio scopes require a tenant assignment.");
logger.LogWarning(
"Client credentials validation failed for {ClientId}: policy scopes require tenant assignment.",
document.ClientId);
return;
}
if (hasAocVerify && !EnsureTenantAssigned())
{
context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = StellaOpsScopes.AocVerify;
context.Reject(OpenIddictConstants.Errors.InvalidClient, "Scope 'aoc:verify' requires a tenant assignment.");
logger.LogWarning(
"Client credentials validation failed for {ClientId}: aoc:verify scope requires tenant assignment.",
document.ClientId);
return;
}
if (grantedScopes.Length > 0 &&
Array.IndexOf(grantedScopes, StellaOpsScopes.EffectiveWrite) >= 0)
{
@@ -416,21 +607,72 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle
var tenantValueForAudit = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientTenantProperty, out var tenantAuditObj) && tenantAuditObj is string tenantAudit
? tenantAudit
: metadata?.Tenant;
var projectValueForAudit = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientProjectProperty, out var projectAuditObj) && projectAuditObj is string projectAudit
? projectAudit
: metadata?.Project;
var extraProperties = new List<AuthEventProperty>();
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.OperatorReasonProperty, out var operatorReasonObj) &&
operatorReasonObj is string operatorReason &&
!string.IsNullOrWhiteSpace(operatorReason))
{
extraProperties.Add(new AuthEventProperty
{
Name = "request.reason",
Value = ClassifiedString.Sensitive(operatorReason)
});
}
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.OperatorTicketProperty, out var operatorTicketObj) &&
operatorTicketObj is string operatorTicket &&
!string.IsNullOrWhiteSpace(operatorTicket))
{
extraProperties.Add(new AuthEventProperty
{
Name = "request.ticket",
Value = ClassifiedString.Sensitive(operatorTicket)
});
}
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ExportAdminReasonProperty, out var exportReasonObj) &&
exportReasonObj is string exportReason &&
!string.IsNullOrWhiteSpace(exportReason))
{
extraProperties.Add(new AuthEventProperty
{
Name = "export.reason",
Value = ClassifiedString.Sensitive(exportReason)
});
}
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ExportAdminTicketProperty, out var exportTicketObj) &&
exportTicketObj is string exportTicket &&
!string.IsNullOrWhiteSpace(exportTicket))
{
extraProperties.Add(new AuthEventProperty
{
Name = "export.ticket",
Value = ClassifiedString.Sensitive(exportTicket)
});
}
var record = ClientCredentialsAuditHelper.CreateRecord(
timeProvider,
context.Transaction,
metadata,
null,
null,
outcome,
reason,
auditClientId,
providerName,
tenantValueForAudit,
projectValueForAudit,
confidentialValue,
requested,
granted,
invalidScope);
invalidScope,
extraProperties.Count > 0 ? extraProperties : null);
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
}
@@ -639,6 +881,28 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
activity?.SetTag("authority.tenant", tenant);
}
string? project = null;
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientProjectProperty, out var projectValue) &&
projectValue is string storedProject &&
!string.IsNullOrWhiteSpace(storedProject))
{
project = storedProject;
}
else if (document.Properties.TryGetValue(AuthorityClientMetadataKeys.Project, out var projectProperty))
{
project = ClientCredentialHandlerHelpers.NormalizeProject(projectProperty);
}
if (string.IsNullOrWhiteSpace(project))
{
project = StellaOpsTenancyDefaults.AnyProject;
}
identity.SetClaim(StellaOpsClaimTypes.Project, project);
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientProjectProperty] = project;
metadataAccessor.SetProject(project);
activity?.SetTag("authority.project", project);
var (providerHandle, descriptor) = await ResolveProviderAsync(context, document).ConfigureAwait(false);
if (context.IsRejected)
{
@@ -705,6 +969,15 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
metadataAccessor.SetTenant(descriptor.Tenant);
activity?.SetTag("authority.tenant", descriptor.Tenant);
}
if (!string.IsNullOrWhiteSpace(descriptor.Project))
{
var normalizedProject = ClientCredentialHandlerHelpers.NormalizeProject(descriptor.Project) ?? StellaOpsTenancyDefaults.AnyProject;
identity.SetClaim(StellaOpsClaimTypes.Project, normalizedProject);
context.Transaction.Properties[AuthorityOpenIddictConstants.ClientProjectProperty] = normalizedProject;
metadataAccessor.SetProject(normalizedProject);
activity?.SetTag("authority.project", normalizedProject);
}
}
var session = await sessionAccessor.GetSessionAsync(context.CancellationToken).ConfigureAwait(false);
@@ -843,6 +1116,13 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<
record.Tenant = tenantValue;
}
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientProjectProperty, out var projectObj) &&
projectObj is string projectValue &&
!string.IsNullOrWhiteSpace(projectValue))
{
record.Project = projectValue;
}
if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.DpopConsumedNonceProperty, out var nonceObj) &&
nonceObj is string nonce &&
!string.IsNullOrWhiteSpace(nonce))
@@ -922,7 +1202,10 @@ internal static class ClientCredentialHandlerHelpers
public static string? NormalizeTenant(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
public static string? NormalizeProject(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
public static (string[] Scopes, string? InvalidScope) ResolveGrantedScopes(
IReadOnlyCollection<string> allowedScopes,
IReadOnlyList<string> requestedScopes)

View File

@@ -9,17 +9,18 @@ using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using OpenIddict.Abstractions;
using OpenIddict.Extensions;
using OpenIddict.Server;
using OpenIddict.Server.AspNetCore;
using StellaOps.Auth.Security.Dpop;
using StellaOps.Authority.OpenIddict;
using OpenIddict.Extensions;
using OpenIddict.Server;
using OpenIddict.Server.AspNetCore;
using StellaOps.Configuration;
using StellaOps.Auth.Security.Dpop;
using StellaOps.Authority.OpenIddict;
using StellaOps.Auth.Abstractions;
using StellaOps.Authority.RateLimiting;
using StellaOps.Authority.Security;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Configuration;
using StellaOps.Cryptography.Audit;
using Microsoft.IdentityModel.Tokens;
@@ -635,25 +636,29 @@ internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenId
var confidential = string.Equals(clientDocument.ClientType, "confidential", StringComparison.OrdinalIgnoreCase);
var tenant = clientDocument.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenantValue)
? tenantValue?.Trim().ToLowerInvariant()
? ClientCredentialHandlerHelpers.NormalizeTenant(tenantValue)
: null;
var project = clientDocument.Properties.TryGetValue(AuthorityClientMetadataKeys.Project, out var projectValue)
? ClientCredentialHandlerHelpers.NormalizeProject(projectValue)
: StellaOpsTenancyDefaults.AnyProject;
var record = ClientCredentialsAuditHelper.CreateRecord(
clock,
context.Transaction,
metadata,
timeProvider: clock,
transaction: context.Transaction,
metadata: metadata,
clientSecret: null,
outcome,
reason,
clientDocument.ClientId,
outcome: outcome,
reason: reason,
clientId: clientDocument.ClientId,
providerName: clientDocument.Plugin,
tenant,
confidential,
tenant: tenant,
project: project,
confidential: confidential,
requestedScopes: Array.Empty<string>(),
grantedScopes: Array.Empty<string>(),
invalidScope: null,
extraProperties: properties,
eventType: eventType);
eventType: eventType);
await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false);
}

View File

@@ -611,16 +611,16 @@ internal static class PasswordGrantAuditHelper
OpenIddictServerTransaction transaction,
AuthorityRateLimiterMetadata? metadata,
AuthEventOutcome outcome,
string? reason,
string? clientId,
string? providerName,
string? tenant,
AuthorityUserDescriptor? user,
string? username,
IEnumerable<string>? scopes,
TimeSpan? retryAfter,
AuthorityCredentialFailureCode? failureCode,
IEnumerable<AuthEventProperty>? extraProperties,
string? reason = null,
string? clientId = null,
string? providerName = null,
string? tenant = null,
AuthorityUserDescriptor? user = null,
string? username = null,
IEnumerable<string>? scopes = null,
TimeSpan? retryAfter = null,
AuthorityCredentialFailureCode? failureCode = null,
IEnumerable<AuthEventProperty>? extraProperties = null,
string? eventType = null)
{
ArgumentNullException.ThrowIfNull(timeProvider);

View File

@@ -99,6 +99,17 @@ internal sealed class PersistTokensHandler : IOpenIddictServerHandler<OpenIddict
{
document.Tenant = tenantClaim.Trim().ToLowerInvariant();
}
var projectClaim = principal.GetClaim(StellaOpsClaimTypes.Project);
if (!string.IsNullOrWhiteSpace(projectClaim))
{
var normalizedProject = projectClaim.Trim().ToLowerInvariant();
document.Project = normalizedProject;
}
else if (string.IsNullOrWhiteSpace(document.Project))
{
document.Project = StellaOpsTenancyDefaults.AnyProject;
}
var senderConstraint = principal.GetClaim(AuthorityOpenIddictConstants.SenderConstraintClaimType);
if (!string.IsNullOrWhiteSpace(senderConstraint))

View File

@@ -72,8 +72,14 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
static string? NormalizeTenant(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
static string NormalizeProject(string? value)
=> string.IsNullOrWhiteSpace(value)
? StellaOpsTenancyDefaults.AnyProject
: value.Trim().ToLowerInvariant();
var identity = context.Principal.Identity as ClaimsIdentity;
var principalTenant = NormalizeTenant(context.Principal.GetClaim(StellaOpsClaimTypes.Tenant));
var principalProject = NormalizeProject(context.Principal.GetClaim(StellaOpsClaimTypes.Project));
using var activity = activitySource.StartActivity("authority.token.validate_access", ActivityKind.Internal);
activity?.SetTag("authority.endpoint", context.EndpointType switch
@@ -142,6 +148,49 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
metadataAccessor.SetTenant(documentTenant);
}
var documentProject = NormalizeProject(tokenDocument.Project);
if (identity is not null)
{
var existingProject = identity.FindFirst(StellaOpsClaimTypes.Project)?.Value;
if (string.IsNullOrWhiteSpace(existingProject))
{
identity.SetClaim(StellaOpsClaimTypes.Project, documentProject);
principalProject = documentProject;
}
else
{
var normalizedExistingProject = NormalizeProject(existingProject);
if (!string.Equals(normalizedExistingProject, documentProject, StringComparison.Ordinal))
{
context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token project does not match the issued project.");
logger.LogWarning(
"Access token validation failed: project mismatch for token {TokenId}. PrincipalProject={PrincipalProject}; DocumentProject={DocumentProject}.",
tokenDocument.TokenId,
normalizedExistingProject,
documentProject);
return;
}
principalProject = normalizedExistingProject;
}
}
else if (!string.Equals(principalProject, documentProject, StringComparison.Ordinal))
{
context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token project does not match the issued project.");
logger.LogWarning(
"Access token validation failed: project mismatch for token {TokenId}. PrincipalProject={PrincipalProject}; DocumentProject={DocumentProject}.",
tokenDocument.TokenId,
principalProject,
documentProject);
return;
}
else
{
principalProject = documentProject;
}
metadataAccessor.SetProject(documentProject);
}
if (!context.IsRejected && tokenDocument is not null)
@@ -191,6 +240,52 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
}
}
if (clientDocument is not null &&
clientDocument.Properties.TryGetValue(AuthorityClientMetadataKeys.Project, out var clientProjectRaw))
{
var clientProject = NormalizeProject(clientProjectRaw);
if (!string.Equals(principalProject, clientProject, StringComparison.Ordinal))
{
if (identity is not null)
{
var existingProject = identity.FindFirst(StellaOpsClaimTypes.Project)?.Value;
if (string.IsNullOrWhiteSpace(existingProject))
{
identity.SetClaim(StellaOpsClaimTypes.Project, clientProject);
principalProject = clientProject;
}
else
{
var normalizedExistingProject = NormalizeProject(existingProject);
if (!string.Equals(normalizedExistingProject, clientProject, StringComparison.Ordinal))
{
context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token project does not match the registered client project.");
logger.LogWarning(
"Access token validation failed: project mismatch for client {ClientId}. PrincipalProject={PrincipalProject}; ClientProject={ClientProject}.",
clientId,
normalizedExistingProject,
clientProject);
return;
}
principalProject = normalizedExistingProject;
}
}
else
{
context.Reject(OpenIddictConstants.Errors.InvalidToken, "The token project does not match the registered client project.");
logger.LogWarning(
"Access token validation failed: project mismatch for client {ClientId}. PrincipalProject={PrincipalProject}; ClientProject={ClientProject}.",
clientId,
principalProject,
clientProject);
return;
}
}
metadataAccessor.SetProject(clientProject);
}
if (identity is null)
{
return;
@@ -201,6 +296,12 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open
metadataAccessor.SetTenant(principalTenant);
}
if (!string.IsNullOrWhiteSpace(principalProject))
{
metadataAccessor.SetProject(principalProject);
activity?.SetTag("authority.project", principalProject);
}
var providerName = context.Principal.GetClaim(StellaOpsClaimTypes.IdentityProvider);
if (string.IsNullOrWhiteSpace(providerName))
{

View File

@@ -45,7 +45,9 @@ internal static class TokenRequestTamperInspector
private static readonly HashSet<string> ClientCredentialsParameters = new(StringComparer.OrdinalIgnoreCase)
{
AuthorityOpenIddictConstants.ProviderParameterName
AuthorityOpenIddictConstants.ProviderParameterName,
AuthorityOpenIddictConstants.OperatorReasonParameterName,
AuthorityOpenIddictConstants.OperatorTicketParameterName
};
internal static IReadOnlyList<string> GetUnexpectedPasswordGrantParameters(OpenIddictRequest request)

View File

@@ -0,0 +1,3 @@
public partial class Program
{
}

View File

@@ -96,7 +96,19 @@ builder.Host.UseSerilog((context, _, loggerConfiguration) =>
});
var authorityOptions = authorityConfiguration.Options;
var issuer = authorityOptions.Issuer ?? throw new InvalidOperationException("Authority issuer configuration is required.");
var issuerUri = authorityOptions.Issuer;
if (issuerUri is null)
{
var issuerValue = builder.Configuration["Authority:Issuer"];
if (string.IsNullOrWhiteSpace(issuerValue))
{
throw new InvalidOperationException("Authority issuer configuration is required.");
}
issuerUri = new Uri(issuerValue, UriKind.Absolute);
}
authorityOptions.Issuer = issuerUri;
builder.Services.AddSingleton(authorityOptions);
builder.Services.AddSingleton<IOptions<StellaOpsAuthorityOptions>>(Options.Create(authorityOptions));
builder.Services.AddHttpContextAccessor();
@@ -210,7 +222,7 @@ builder.Services.AddAuthorization();
builder.Services.AddOpenIddict()
.AddServer(options =>
{
options.SetIssuer(issuer);
options.SetIssuer(issuerUri);
options.SetTokenEndpointUris("/token");
options.SetAuthorizationEndpointUris("/authorize");
options.SetIntrospectionEndpointUris("/introspect");
@@ -806,6 +818,20 @@ if (authorityOptions.Bootstrap.Enabled)
certificateBindings = bindingRegistrations;
}
var requestedTenant = properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenantMetadata)
? ClientCredentialHandlerHelpers.NormalizeTenant(tenantMetadata)
: null;
if (!string.IsNullOrWhiteSpace(requestedTenant))
{
properties[AuthorityClientMetadataKeys.Tenant] = requestedTenant;
}
var requestedProject = properties.TryGetValue(AuthorityClientMetadataKeys.Project, out var projectMetadata)
? ClientCredentialHandlerHelpers.NormalizeProject(projectMetadata)
: null;
requestedProject ??= StellaOpsTenancyDefaults.AnyProject;
properties[AuthorityClientMetadataKeys.Project] = requestedProject;
var registration = new AuthorityClientRegistration(
request.ClientId,
request.Confidential,
@@ -816,9 +842,8 @@ if (authorityOptions.Bootstrap.Enabled)
request.AllowedAudiences ?? Array.Empty<string>(),
redirectUris,
postLogoutUris,
properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var requestedTenant)
? requestedTenant?.Trim().ToLowerInvariant()
: null,
requestedTenant,
requestedProject,
properties,
certificateBindings);

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Authority.RateLimiting;
@@ -41,6 +42,11 @@ internal sealed class AuthorityRateLimiterMetadata
/// </summary>
public string? Tenant { get; set; }
/// <summary>
/// Project identifier associated with the request, when available.
/// </summary>
public string? Project { get; set; } = StellaOpsTenancyDefaults.AnyProject;
/// <summary>
/// Additional metadata tags that can be attached by later handlers.
/// </summary>

View File

@@ -1,5 +1,6 @@
using System;
using Microsoft.AspNetCore.Http;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Authority.RateLimiting;
@@ -29,6 +30,11 @@ internal interface IAuthorityRateLimiterMetadataAccessor
/// </summary>
void SetTenant(string? tenant);
/// <summary>
/// Updates the project identifier associated with the current request.
/// </summary>
void SetProject(string? project);
/// <summary>
/// Adds or removes a metadata tag for the current request.
/// </summary>
@@ -79,6 +85,16 @@ internal sealed class AuthorityRateLimiterMetadataAccessor : IAuthorityRateLimit
}
}
public void SetProject(string? project)
{
var metadata = TryGetMetadata();
if (metadata is not null)
{
metadata.Project = NormalizeProject(project);
metadata.SetTag("authority.project", metadata.Project);
}
}
public void SetTag(string name, string? value)
{
var metadata = TryGetMetadata();
@@ -100,4 +116,14 @@ internal sealed class AuthorityRateLimiterMetadataAccessor : IAuthorityRateLimit
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
}
private static string? NormalizeProject(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return StellaOpsTenancyDefaults.AnyProject;
}
return value.Trim().ToLowerInvariant();
}
}

View File

@@ -13,20 +13,26 @@
<PackageReference Include="OpenIddict.Server.AspNetCore" Version="6.4.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageReference Include="StackExchange.Redis" Version="2.8.24" />
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Plugin.Standard\StellaOps.Authority.Plugin.Standard.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj" />
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageReference Include="StackExchange.Redis" Version="2.8.24" />
<PackageReference Include="YamlDotNet" Version="13.7.1" />
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Plugin.Standard\StellaOps.Authority.Plugin.Standard.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj" />
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="..\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj" />
<ProjectReference Include="..\..\StellaOps.Auth.Security\StellaOps.Auth.Security.csproj" />
<ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
<ProjectReference Include="..\..\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj" />
</ItemGroup>
</Project>
<ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" />
<ProjectReference Include="..\..\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\StellaOps.Api.OpenApi\authority\openapi.yaml" Link="OpenApi\authority.yaml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,43 @@
using StellaOps.Configuration;
namespace StellaOps.Authority.Tenants;
public interface IAuthorityTenantCatalog
{
IReadOnlyList<AuthorityTenantView> GetTenants();
}
public sealed class AuthorityTenantCatalog : IAuthorityTenantCatalog
{
private readonly IReadOnlyList<AuthorityTenantView> tenants;
public AuthorityTenantCatalog(StellaOpsAuthorityOptions authorityOptions)
{
if (authorityOptions is null)
{
throw new ArgumentNullException(nameof(authorityOptions));
}
tenants = authorityOptions.Tenants.Count == 0
? Array.Empty<AuthorityTenantView>()
: authorityOptions.Tenants
.Select(t => new AuthorityTenantView(
t.Id,
string.IsNullOrWhiteSpace(t.DisplayName) ? t.Id : t.DisplayName,
string.IsNullOrWhiteSpace(t.Status) ? "active" : t.Status,
string.IsNullOrWhiteSpace(t.IsolationMode) ? "shared" : t.IsolationMode,
t.DefaultRoles.Count == 0 ? Array.Empty<string>() : t.DefaultRoles.ToArray(),
t.Projects.Count == 0 ? Array.Empty<string>() : t.Projects.ToArray()))
.ToArray();
}
public IReadOnlyList<AuthorityTenantView> GetTenants() => tenants;
}
public sealed record AuthorityTenantView(
string Id,
string DisplayName,
string Status,
string IsolationMode,
IReadOnlyList<string> DefaultRoles,
IReadOnlyList<string> Projects);

View File

@@ -9,6 +9,15 @@
| AUTH-AOC-19-003 | DONE (2025-10-27) | Authority Core & Docs Guild | AUTH-AOC-19-001 | Update Authority docs and sample configs to describe new scopes, tenancy enforcement, and verify endpoints. | Docs and examples refreshed; release notes prepared; smoke tests confirm new scopes required. |
> 2025-10-26: Docs updated (`docs/11_AUTHORITY.md`, Concelier audit runbook, `docs/security/authority-scopes.md`); sample config highlights tenant-aware clients. Release notes + smoke verification pending (blocked on Concelier/Excititor smoke updates).
> 2025-10-27: Scope catalogue aligned with `advisory:ingest/advisory:read/vex:ingest/vex:read`, `aoc:verify` pairing documented, console/CLI references refreshed, and `etc/authority.yaml.sample` updated to require read scopes for verification clients.
| AUTH-AOC-19-004 | DONE (2025-10-31) | Authority Core & Security Guild | AUTH-AOC-19-002 | Enforce AOC scope pairings: require `aoc:verify` alongside advisory/vex read scopes and for any `signals:*` requests; emit deterministic errors and telemetry. | Client/token issuance rejects missing pairings with structured errors; logs/metrics capture violations; tests and docs updated. |
> 2025-10-31: Client credentials and password grants now reject advisory/vex read or signals scopes without `aoc:verify`, enforce tenant assignment for `aoc:verify`, tag violations via `authority.aoc_scope_violation`, extend tests, and refresh scope catalogue docs/sample roles.
## Link-Not-Merge v1
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AUTH-AOC-22-001 | DONE (2025-10-29) | Authority Core Guild | AUTH-AOC-19-001 | Roll out new advisory/vex ingest/read scopes. | Legacy scopes rejected; metadata/docs/configs updated; integration tests cover advisory/vex scope enforcement for Link-Not-Merge APIs. |
> 2025-10-29: Rejected legacy `concelier.merge` scope during client credential validation, removed it from known scope catalog, blocked discovery/issuance, added regression tests, and refreshed scope documentation.
## Policy Engine v2
@@ -32,40 +41,54 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AUTH-POLICY-23-001 | TODO | Authority Core & Security Guild | AUTH-POLICY-20-001 | Introduce fine-grained scopes `policy:read`, `policy:edit`, `policy:approve`, `policy:activate`, `policy:simulate`; update issuer templates and metadata. | Scopes exposed; integration tests confirm enforcement; offline kit updated. |
| AUTH-POLICY-23-002 | TODO | Authority Core & Security Guild | AUTH-POLICY-23-001 | Implement optional two-person rule for activation: require two distinct `policy:activate` approvals when configured; emit audit logs. | Activation endpoint enforces rule; audit logs contain approver IDs; tests cover 2-person path. |
| AUTH-POLICY-23-003 | TODO | Authority Core & Docs Guild | AUTH-POLICY-23-001 | Update documentation and sample configs for policy roles, approval workflow, and signing requirements. | Docs updated with reviewer checklist; configuration examples validated. |
| AUTH-POLICY-23-001 | DONE (2025-10-29) | Authority Core & Security Guild | AUTH-POLICY-20-001 | Introduce fine-grained scopes `policy:read`, `policy:edit`, `policy:approve`, `policy:activate`, `policy:simulate`; update issuer templates and metadata. | Scopes exposed; integration tests confirm enforcement; offline kit updated. |
| AUTH-POLICY-23-002 | BLOCKED (2025-10-29) | Authority Core & Security Guild | AUTH-POLICY-23-001 | Implement optional two-person rule for activation: require two distinct `policy:activate` approvals when configured; emit audit logs. | Activation endpoint enforces rule; audit logs contain approver IDs; tests cover 2-person path. |
> Blocked: Policy Engine/Studio have not yet exposed activation workflow endpoints or approval payloads needed to enforce dual-control (`WEB-POLICY-23-002`, `POLICY-ENGINE-23-002`). Revisit once activation contract lands.
| AUTH-POLICY-23-003 | BLOCKED (2025-10-29) | Authority Core & Docs Guild | AUTH-POLICY-23-001 | Update documentation and sample configs for policy roles, approval workflow, and signing requirements. | Docs updated with reviewer checklist; configuration examples validated. |
> Blocked pending AUTH-POLICY-23-002 dual-approval implementation so docs can capture final activation behaviour.
| AUTH-POLICY-23-004 | DONE (2025-10-27) | Authority Core & DevOps Guild | AUTH-POLICY-23-001 | Migrate default Authority client registrations/offline kit templates to the new policy scope set and provide migration guidance for existing tokens. | Updated configs committed (Authority, CLI, CI samples); migration note added to release docs; verification script confirms scopes. |
> 2025-10-27: Added `policy-cli` defaults to Authority config/secrets, refreshed CLI/CI documentation with the new scope bundle, recorded release migration guidance, and introduced `scripts/verify-policy-scopes.py` to guard against regressions.
## Graph & Vuln Explorer v1
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AUTH-VULN-24-001 | TODO | Authority Core & Security Guild | AUTH-GRAPH-21-001 | Extend scopes to include `vuln:read` and signed permalinks with scoped claims for Vuln Explorer; update metadata. | Scopes published; permalinks validated; integration tests cover RBAC. |
| AUTH-VULN-24-001 | DONE (2025-10-29) | Authority Core & Security Guild | AUTH-GRAPH-21-001 | Extend scopes to include `vuln:read` and signed permalinks with scoped claims for Vuln Explorer; update metadata. | Scopes published; permalinks validated; integration tests cover RBAC. |
> 2025-10-27: Paused work after exploratory spike (scope enforcement still outstanding); no functional changes merged.
## Orchestrator Dashboard
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AUTH-ORCH-32-001 | TODO | Authority Core & Security Guild | — | Define `orch:read` scope, register `Orch.Viewer` role, update discovery metadata, and seed offline defaults. | Scope/role available in metadata; integration tests confirm read-only enforcement; offline kit updated. |
| AUTH-ORCH-33-001 | TODO | Authority Core & Security Guild | AUTH-ORCH-32-001 | Add `Orch.Operator` role/scopes for control actions, require reason/ticket attributes, and update issuer templates. | Operator tokens issued; action endpoints enforce scope + reason; audit logs capture operator info; docs refreshed. |
| AUTH-ORCH-32-001 | DONE (2025-10-31) | Authority Core & Security Guild | — | Define `orch:read` scope, register `Orch.Viewer` role, update discovery metadata, and seed offline defaults. | Scope/role available in metadata; integration tests confirm read-only enforcement; offline kit updated. |
> 2025-10-31: Picked up during Console/Orchestrator alignment; focusing on scope catalog + tenant enforcement first.
> 2025-10-31: `orch:read` added to scope catalogue and Authority runtime, Console defaults include the scope, `Orch.Viewer` role documented, and client-credential tests enforce tenant requirements.
| AUTH-ORCH-33-001 | DOING (2025-10-27) | Authority Core & Security Guild | AUTH-ORCH-32-001 | Add `Orch.Operator` role/scopes for control actions, require reason/ticket attributes, and update issuer templates. | Operator tokens issued; action endpoints enforce scope + reason; audit logs capture operator info; docs refreshed. |
> 2025-10-27: Added `orch:operate` scope, enforced `operator_reason`/`operator_ticket` on token issuance, updated Authority configs/docs, and captured audit metadata for control actions.
> 2025-10-28: Policy gateway + scanner now pass the expanded token client signature (`null` metadata by default), test stubs capture the optional parameters, and Policy Gateway/Scanner suites are green after fixing the Concelier storage build break.
> 2025-10-28: Authority password-grant tests now hit the new constructors but still need updates to drop obsolete `IOptions` arguments before the suite can pass.
| AUTH-ORCH-34-001 | TODO | Authority Core & Security Guild | AUTH-ORCH-33-001 | Introduce `Orch.Admin` role with quota/backfill scopes, enforce audit reason on quota changes, and update offline defaults/docs. | Admin role available; quotas/backfills require scope + reason; tests confirm tenant isolation; documentation updated. |
## StellaOps Console (Sprint 23)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AUTH-CONSOLE-23-001 | TODO | Authority Core & Security Guild | AUTH-POLICY-20-001 | Register StellaOps Console confidential client with OIDC PKCE support, short-lived ID/access tokens, `console:*` audience claims, and SPA-friendly refresh (token exchange endpoint). Publish discovery metadata + offline kit defaults. | Client registration committed, configuration templates updated, integration tests validate PKCE + scope issuance, security review recorded. |
| AUTH-CONSOLE-23-002 | TODO | Authority Core & Security Guild | AUTH-CONSOLE-23-001, AUTH-AOC-19-002 | Expose tenant catalog, user profile, and token introspection endpoints required by Console (fresh-auth prompts, scope checks); enforce tenant header requirements and audit logging with correlation IDs. | Endpoints ship with RBAC enforcement, audit logs include tenant+scope, integration tests cover unauthorized/tenant-mismatch scenarios. |
| AUTH-CONSOLE-23-003 | TODO | Authority Core & Docs Guild | AUTH-CONSOLE-23-001, AUTH-CONSOLE-23-002 | Update security docs/config samples for Console flows (PKCE, tenant badge, fresh-auth for admin actions, session inactivity timeouts) with compliance checklist. | Docs merged, config samples validated, release notes updated, ops runbook references new flows. |
| AUTH-CONSOLE-23-001 | DONE (2025-10-29) | Authority Core & Security Guild | AUTH-POLICY-20-001 | Register StellaOps Console confidential client with OIDC PKCE support, short-lived ID/access tokens, `console:*` audience claims, and SPA-friendly refresh (token exchange endpoint). Publish discovery metadata + offline kit defaults. | Client registration committed; configuration templates updated; integration tests validate PKCE + scope issuance; security review recorded. |
> 2025-10-29: Authorization code flow enabled with PKCE requirement, console client seeded in `authority.yaml.sample`, discovery docs updated, and console runbook guidance added.
| AUTH-CONSOLE-23-002 | DONE (2025-10-31) | Authority Core & Security Guild | AUTH-CONSOLE-23-001, AUTH-AOC-19-002 | Expose tenant catalog, user profile, and token introspection endpoints required by Console (fresh-auth prompts, scope checks); enforce tenant header requirements and audit logging with correlation IDs. | Endpoints ship with RBAC enforcement, audit logs include tenant+scope, integration tests cover unauthorized/tenant-mismatch scenarios. |
> 2025-10-31: Added `/console/tenants`, `/console/profile`, `/console/token/introspect` endpoints with tenant header filter, scope enforcement (`ui.read`, `authority:tenants.read`), and structured audit events. Console test harness covers success/mismatch cases.
| AUTH-CONSOLE-23-003 | DONE (2025-10-31) | Authority Core & Docs Guild | AUTH-CONSOLE-23-001, AUTH-CONSOLE-23-002 | Update security docs/config samples for Console flows (PKCE, tenant badge, fresh-auth for admin actions, session inactivity timeouts) with compliance checklist. | Docs merged, config samples validated, release notes updated, ops runbook references new flows. |
> 2025-10-28: `docs/security/console-security.md` drafted with PKCE + DPoP (120s OpTok, 300s fresh-auth) and scope table. Authority Core to confirm `/fresh-auth` semantics, token lifetimes, and scope bundles align before closing task.
| AUTH-CONSOLE-23-004 | TODO | Authority Core & Security Guild | AUTH-CONSOLE-23-003, DOCS-CONSOLE-23-012 | Validate console security guide assumptions (120s OpTok TTL, 300s fresh-auth window, scope bundles) against Authority implementation and update configs/audit fixtures if needed. | Confirmation recorded in sprint log; Authority config samples/tests updated when adjustments required; `/fresh-auth` behaviour documented in release notes. |
> 2025-10-31: Security guide expanded for `/console` endpoints & orchestrator scope, sample YAML annotated, ops runbook updated, and release note `docs/updates/2025-10-31-console-security-refresh.md` published.
| AUTH-CONSOLE-23-004 | DONE (2025-10-31) | Authority Core & Security Guild | AUTH-CONSOLE-23-003, DOCS-CONSOLE-23-012 | Validate console security guide assumptions (120s OpTok TTL, 300s fresh-auth window, scope bundles) against Authority implementation and update configs/audit fixtures if needed. | Confirmation recorded in sprint log; Authority config samples/tests updated when adjustments required; `/fresh-auth` behaviour documented in release notes. |
> 2025-10-31: Default access-token lifetime reduced to 120s, console tests updated with dual auth schemes, docs/config/ops notes refreshed, release note logged.
## Policy Studio (Sprint 27)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AUTH-POLICY-27-001 | TODO | Authority Core & Security Guild | AUTH-POLICY-20-001, AUTH-CONSOLE-23-001 | Define Policy Studio roles (`policy:author`, `policy:review`, `policy:approve`, `policy:operate`, `policy:audit`) with tenant-scoped claims, update issuer metadata, and seed offline kit defaults. | Scopes/roles exposed via discovery docs; tokens issued with correct claims; integration tests cover role combinations; docs updated. |
| AUTH-POLICY-27-001 | DONE (2025-10-31) | Authority Core & Security Guild | AUTH-POLICY-20-001, AUTH-CONSOLE-23-001 | Define Policy Studio roles (`policy:author`, `policy:review`, `policy:approve`, `policy:operate`, `policy:audit`) with tenant-scoped claims, update issuer metadata, and seed offline kit defaults. | Scopes/roles exposed via discovery docs; tokens issued with correct claims; integration tests cover role combinations; docs updated. |
> 2025-10-31: Added Policy Studio scope family (`policy:author/review/operate/audit`), updated OpenAPI + discovery headers, enforced tenant requirements in grant handlers, seeded new roles in Authority config/offline kit docs, and refreshed CLI/Console documentation + tests to validate the new catalogue.
| AUTH-POLICY-27-002 | TODO | Authority Core & Security Guild | AUTH-POLICY-27-001, REGISTRY-API-27-007 | Provide attestation signing service bindings (OIDC token exchange, cosign integration) and enforce publish/promote scope checks, fresh-auth requirements, and audit logging. | Publish/promote requests require fresh auth + correct scopes; attestations signed with validated identity; audit logs enriched with digest + tenant; integration tests pass. |
> Docs dependency: `DOCS-POLICY-27-009` awaiting signing guidance from this work.
| AUTH-POLICY-27-003 | TODO | Authority Core & Docs Guild | AUTH-POLICY-27-001, AUTH-POLICY-27-002 | Update Authority configuration/docs for Policy Studio roles, signing policies, approval workflows, and CLI integration; include compliance checklist. | Docs merged; samples validated; governance checklist appended; release notes updated. |
@@ -74,14 +97,17 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AUTH-EXC-25-001 | TODO | Authority Core & Security Guild | AUTH-POLICY-23-001 | Introduce exception scopes (`exceptions:read`, `exceptions:write`, `exceptions:approve`) and approval routing configuration with MFA gating. | Scopes published in metadata; routing matrix validated; integration tests enforce scope + MFA rules. |
| AUTH-EXC-25-002 | TODO | Authority Core & Docs Guild | AUTH-EXC-25-001 | Update documentation/samples for exception roles, routing matrix, MFA requirements, and audit trail references. | Docs merged with compliance checklist; samples verified. |
| AUTH-EXC-25-001 | DONE (2025-10-29) | Authority Core & Security Guild | AUTH-POLICY-23-001 | Introduce exception scopes (`exceptions:read`, `exceptions:write`, `exceptions:approve`) and approval routing configuration with MFA gating. | Scopes published in metadata; routing matrix validated; integration tests enforce scope + MFA rules. |
> 2025-10-29: Added exception scopes + routing template options, enforced MFA requirement in password grant handlers, updated configuration samples.
| AUTH-EXC-25-002 | DONE (2025-10-31) | Authority Core & Docs Guild | AUTH-EXC-25-001 | Update documentation/samples for exception roles, routing matrix, MFA requirements, and audit trail references. | Docs merged with compliance checklist; samples verified. |
> 2025-10-31: Authority scopes/routing docs updated (`docs/security/authority-scopes.md`, `docs/11_AUTHORITY.md`, `docs/policy/exception-effects.md`), monitoring guide covers new MFA audit events, and `etc/authority.yaml.sample` now demonstrates exception clients/templates.
## Reachability v1
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AUTH-SIG-26-001 | TODO | Authority Core & Security Guild | AUTH-EXC-25-001 | Add `signals:read`, `signals:write`, `signals:admin` scopes, issue `SignalsUploader` role template, and enforce AOC for sensor identities. | Scopes exposed; configuration validated; integration tests ensure RBAC + AOC enforcement. |
| AUTH-SIG-26-001 | DONE (2025-10-29) | Authority Core & Security Guild | AUTH-EXC-25-001 | Add `signals:read`, `signals:write`, `signals:admin` scopes, issue `SignalsUploader` role template, and enforce AOC for sensor identities. | Scopes exposed; configuration validated; integration tests ensure RBAC + AOC enforcement. |
> 2025-10-29: Signals scopes added with tenant + aoc:verify enforcement; sensors guided via SignalsUploader template; tests cover gating.
## Vulnerability Explorer (Sprint 29)
@@ -101,8 +127,8 @@
## Export Center
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AUTH-EXPORT-35-001 | TODO | Authority Core & Security Guild | AUTH-AOC-19-001 | Introduce `Export.Viewer`, `Export.Operator`, `Export.Admin` scopes, configure issuer templates, and update discovery metadata/offline defaults. | Scopes available; metadata updated; tests ensure enforcement; offline kit defaults refreshed. |
| AUTH-EXPORT-37-001 | TODO | Authority Core & Security Guild | AUTH-EXPORT-35-001, WEB-EXPORT-37-001 | Enforce admin-only access for scheduling, retention, encryption key references, and verify endpoints with audit reason capture. | Admin scope required; audit logs include reason/ticket; integration tests cover denial cases; docs updated. |
| AUTH-EXPORT-35-001 | DONE (2025-10-28) | Authority Core & Security Guild | AUTH-AOC-19-001 | Introduce `Export.Viewer`, `Export.Operator`, `Export.Admin` scopes, configure issuer templates, and update discovery metadata/offline defaults. | Scopes available; metadata updated; tests ensure enforcement; offline kit defaults refreshed. |
| AUTH-EXPORT-37-001 | DONE (2025-10-28) | Authority Core & Security Guild | AUTH-EXPORT-35-001, WEB-EXPORT-37-001 | Enforce admin-only access for scheduling, retention, encryption key references, and verify endpoints with audit reason capture. | Admin scope required; audit logs include reason/ticket; integration tests cover denial cases; docs updated. |
## Notifications Studio
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
@@ -114,12 +140,14 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AUTH-PACKS-41-001 | TODO | Authority Core & Security Guild | AUTH-AOC-19-001 | Define CLI SSO profiles and pack scopes (`Packs.Read`, `Packs.Write`, `Packs.Run`, `Packs.Approve`), update discovery metadata, offline defaults, and issuer templates. | Scopes available; metadata updated; tests ensure enforcement; offline kit templates refreshed. |
| AUTH-PACKS-43-001 | TODO | Authority Core & Security Guild | AUTH-PACKS-41-001, ORCH-SVC-42-101 | Enforce pack signing policies, approval RBAC checks, CLI CI token scopes, and audit logging for approvals. | Signing policies enforced; approvals require correct roles; CI token scope tests pass; audit logs recorded. |
| AUTH-PACKS-43-001 | BLOCKED (2025-10-27) | Authority Core & Security Guild | AUTH-PACKS-41-001, TASKRUN-42-001, ORCH-SVC-42-101 | Enforce pack signing policies, approval RBAC checks, CLI CI token scopes, and audit logging for approvals. | Signing policies enforced; approvals require correct roles; CI token scope tests pass; audit logs recorded. |
> Blocked: Pack scopes (`AUTH-PACKS-41-001`) and Task Runner pack approvals (`ORCH-SVC-42-101`, `TASKRUN-42-001`) are still TODO. Authority lacks baseline `Packs.*` scope definitions and approval/audit endpoints to enforce policies. Revisit once dependent teams deliver scope catalog + Task Runner approval API.
## Authority-Backed Scopes & Tenancy (Epic 14)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AUTH-TEN-47-001 | TODO | Authority Core & Security Guild | AUTH-AOC-19-001 | Align Authority with OIDC/JWT claims (tenants, projects, scopes), implement JWKS caching/rotation, publish scope grammar, and enforce required claims on tokens. | Tokens include tenant/project claims; JWKS cache validated; docs updated; imposed rule noted. |
| AUTH-TEN-47-001 | DOING (2025-10-28) | Authority Core & Security Guild | AUTH-AOC-19-001 | Align Authority with OIDC/JWT claims (tenants, projects, scopes), implement JWKS caching/rotation, publish scope grammar, and enforce required claims on tokens. | Tokens include tenant/project claims; JWKS cache validated; docs updated; imposed rule noted. |
> 2025-10-28: Tidied advisory raw idempotency migration to avoid LINQ-on-`BsonValue` (explicit array copy) while continuing duplicate guardrail validation; scoped scanner/policy token call sites updated to honor new metadata parameter.
| AUTH-TEN-49-001 | TODO | Authority Core & Security Guild | AUTH-TEN-47-001 | Implement service accounts & delegation tokens (`act` chain), per-tenant quotas, audit stream of auth decisions, and revocation APIs. | Service tokens minted with scopes/TTL; delegation logged; quotas configurable; audit stream live; docs updated. |
## Observability & Forensics (Epic 15)
@@ -141,7 +169,9 @@
## SDKs & OpenAPI (Epic 17)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AUTH-OAS-61-001 | TODO | Authority Core & Security Guild, API Contracts Guild | OAS-61-001 | Document Authority authentication/token endpoints in OAS with scopes, examples, and error envelopes. | Spec complete with security schemes; lint passes. |
| AUTH-OAS-61-002 | TODO | Authority Core & Security Guild | AUTH-OAS-61-001 | Implement `/.well-known/openapi` with scope metadata, supported grant types, and build version. | Endpoint deployed; contract tests cover discovery. |
| AUTH-OAS-61-001 | DONE (2025-10-28) | Authority Core & Security Guild, API Contracts Guild | OAS-61-001 | Document Authority authentication/token endpoints in OAS with scopes, examples, and error envelopes. | Spec complete with security schemes; lint passes. |
> 2025-10-28: Auth OpenAPI authored at `src/StellaOps.Api.OpenApi/authority/openapi.yaml` covering `/token`, `/introspect`, `/revoke`, `/jwks`, scope catalog, and error envelopes; parsed via PyYAML sanity check and referenced in Epic 17 docs.
| AUTH-OAS-61-002 | DONE (2025-10-28) | Authority Core & Security Guild | AUTH-OAS-61-001 | Implement `/.well-known/openapi` with scope metadata, supported grant types, and build version. | Endpoint deployed; contract tests cover discovery. |
> 2025-10-28: Added `/.well-known/openapi` endpoint wiring cached spec metadata, YAML/JSON negotiation, HTTP cache headers, and tests verifying ETag + Accept handling. Authority spec (`src/StellaOps.Api.OpenApi/authority/openapi.yaml`) now includes grant/scope extensions.
| AUTH-OAS-62-001 | TODO | Authority Core & Security Guild, SDK Generator Guild | AUTH-OAS-61-001, SDKGEN-63-001 | Provide SDK helpers for OAuth2/PAT flows, tenancy override header; add integration tests. | SDKs expose auth helpers; tests cover token issuance; docs updated. |
| AUTH-OAS-63-001 | TODO | Authority Core & Security Guild, API Governance Guild | APIGOV-63-001 | Emit deprecation headers and notifications for legacy auth endpoints. | Headers emitted; notifications verified; migration guide published. |

View File

@@ -13,8 +13,8 @@
|----|--------|----------|------------|-------------|---------------|
| BENCH-POLICY-20-001 | DONE (2025-10-26) | Bench Guild, Policy Guild | POLICY-ENGINE-20-002, POLICY-ENGINE-20-006 | Build policy evaluation benchmark suite (100k components, 1M advisories) capturing latency, throughput, memory. | Bench harness committed; baseline metrics recorded; ties into CI dashboards. |
> 2025-10-26: Added `StellaOps.Bench.PolicyEngine` harness, synthetic dataset generator, baseline + Prom/JSON outputs; default thresholds cover latency/throughput/allocation.
| BENCH-POLICY-20-002 | BLOCKED (waiting on SCHED-WORKER-20-302) | Bench Guild, Policy Guild, Scheduler Guild | BENCH-POLICY-20-001, SCHED-WORKER-20-302 | Add incremental run benchmark measuring delta evaluation vs full; capture SLA compliance. | Incremental bench executed; results stored; regression alerts configured. |
> 2025-10-26: Scheduler delta targeting (SCHED-WORKER-20-302) not implemented; incremental bench paused until worker emits delta input stream.
| BENCH-POLICY-20-002 | TODO | Bench Guild, Policy Guild, Scheduler Guild | BENCH-POLICY-20-001, SCHED-WORKER-20-302 | Add incremental run benchmark measuring delta evaluation vs full; capture SLA compliance. | Incremental bench executed; results stored; regression alerts configured. |
> 2025-10-29: Scheduler delta targeting landed (see SCHED-WORKER-20-302 notes); incremental bench can proceed once Policy Engine change streams feed metadata.
## Graph Explorer v1

File diff suppressed because it is too large Load Diff

View File

@@ -33,8 +33,10 @@ internal static class CommandFactory
root.Add(BuildScanCommand(services, options, verboseOption, cancellationToken));
root.Add(BuildDatabaseCommand(services, verboseOption, cancellationToken));
root.Add(BuildSourcesCommand(services, verboseOption, cancellationToken));
root.Add(BuildAocCommand(services, verboseOption, cancellationToken));
root.Add(BuildAuthCommand(services, options, verboseOption, cancellationToken));
root.Add(BuildPolicyCommand(services, options, verboseOption, cancellationToken));
root.Add(BuildFindingsCommand(services, verboseOption, cancellationToken));
root.Add(BuildConfigCommand(options));
root.Add(BuildVulnCommand(services, verboseOption, cancellationToken));
@@ -318,6 +320,91 @@ internal static class CommandFactory
return sources;
}
private static Command BuildAocCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var aoc = new Command("aoc", "Aggregation-Only Contract verification commands.");
var verify = new Command("verify", "Verify stored raw documents against AOC guardrails.");
var sinceOption = new Option<string?>("--since")
{
Description = "Verification window start (ISO-8601 timestamp) or relative duration (e.g. 24h, 7d)."
};
var limitOption = new Option<int?>("--limit")
{
Description = "Maximum number of violations to include per code (0 = no limit)."
};
var sourcesOption = new Option<string?>("--sources")
{
Description = "Comma-separated list of sources (e.g. redhat,ubuntu,osv)."
};
var codesOption = new Option<string?>("--codes")
{
Description = "Comma-separated list of violation codes (ERR_AOC_00x)."
};
var formatOption = new Option<string>("--format")
{
Description = "Output format: table or json."
};
var exportOption = new Option<string?>("--export")
{
Description = "Write the JSON report to the specified file path."
};
var tenantOption = new Option<string?>("--tenant")
{
Description = "Tenant identifier override."
};
var noColorOption = new Option<bool>("--no-color")
{
Description = "Disable ANSI colouring in console output."
};
verify.Add(sinceOption);
verify.Add(limitOption);
verify.Add(sourcesOption);
verify.Add(codesOption);
verify.Add(formatOption);
verify.Add(exportOption);
verify.Add(tenantOption);
verify.Add(noColorOption);
verify.SetAction((parseResult, _) =>
{
var since = parseResult.GetValue(sinceOption);
var limit = parseResult.GetValue(limitOption);
var sources = parseResult.GetValue(sourcesOption);
var codes = parseResult.GetValue(codesOption);
var format = parseResult.GetValue(formatOption) ?? "table";
var export = parseResult.GetValue(exportOption);
var tenant = parseResult.GetValue(tenantOption);
var noColor = parseResult.GetValue(noColorOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandleAocVerifyAsync(
services,
since,
limit,
sources,
codes,
format,
export,
tenant,
noColor,
verbose,
cancellationToken);
});
aoc.Add(verify);
return aoc;
}
private static Command BuildAuthCommand(IServiceProvider services, StellaOpsCliOptions options, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var auth = new Command("auth", "Manage authentication with StellaOps Authority.");
@@ -493,9 +580,281 @@ internal static class CommandFactory
});
policy.Add(simulate);
var activate = new Command("activate", "Activate an approved policy revision.");
var activatePolicyIdArgument = new Argument<string>("policy-id")
{
Description = "Policy identifier (e.g. P-7)."
};
activate.Add(activatePolicyIdArgument);
var activateVersionOption = new Option<int>("--version")
{
Description = "Revision version to activate."
};
var activationNoteOption = new Option<string?>("--note")
{
Description = "Optional activation note recorded with the approval."
};
var runNowOption = new Option<bool>("--run-now")
{
Description = "Trigger an immediate full policy run after activation."
};
var scheduledAtOption = new Option<string?>("--scheduled-at")
{
Description = "Schedule activation at the provided ISO-8601 timestamp."
};
var priorityOption = new Option<string?>("--priority")
{
Description = "Optional activation priority label (e.g. low, standard, high)."
};
var rollbackOption = new Option<bool>("--rollback")
{
Description = "Indicate that this activation is a rollback to a previous version."
};
var incidentOption = new Option<string?>("--incident")
{
Description = "Associate the activation with an incident identifier."
};
activate.Add(activateVersionOption);
activate.Add(activationNoteOption);
activate.Add(runNowOption);
activate.Add(scheduledAtOption);
activate.Add(priorityOption);
activate.Add(rollbackOption);
activate.Add(incidentOption);
activate.SetAction((parseResult, _) =>
{
var policyId = parseResult.GetValue(activatePolicyIdArgument) ?? string.Empty;
var version = parseResult.GetValue(activateVersionOption);
var note = parseResult.GetValue(activationNoteOption);
var runNow = parseResult.GetValue(runNowOption);
var scheduledAt = parseResult.GetValue(scheduledAtOption);
var priority = parseResult.GetValue(priorityOption);
var rollback = parseResult.GetValue(rollbackOption);
var incident = parseResult.GetValue(incidentOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandlePolicyActivateAsync(
services,
policyId,
version,
note,
runNow,
scheduledAt,
priority,
rollback,
incident,
verbose,
cancellationToken);
});
policy.Add(activate);
return policy;
}
private static Command BuildFindingsCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var findings = new Command("findings", "Inspect policy findings.");
var list = new Command("ls", "List effective findings that match the provided filters.");
var policyOption = new Option<string>("--policy")
{
Description = "Policy identifier (e.g. P-7).",
Required = true
};
var sbomOption = new Option<string[]>("--sbom")
{
Description = "Filter by SBOM identifier (repeatable).",
Arity = ArgumentArity.ZeroOrMore
};
sbomOption.AllowMultipleArgumentsPerToken = true;
var statusOption = new Option<string[]>("--status")
{
Description = "Filter by finding status (repeatable).",
Arity = ArgumentArity.ZeroOrMore
};
statusOption.AllowMultipleArgumentsPerToken = true;
var severityOption = new Option<string[]>("--severity")
{
Description = "Filter by severity label (repeatable).",
Arity = ArgumentArity.ZeroOrMore
};
severityOption.AllowMultipleArgumentsPerToken = true;
var sinceOption = new Option<string?>("--since")
{
Description = "Filter by last-updated timestamp (ISO-8601)."
};
var cursorOption = new Option<string?>("--cursor")
{
Description = "Resume listing from the provided cursor."
};
var pageOption = new Option<int?>("--page")
{
Description = "Page number (starts at 1)."
};
var pageSizeOption = new Option<int?>("--page-size")
{
Description = "Results per page (default backend limit applies)."
};
var formatOption = new Option<string?>("--format")
{
Description = "Output format: table or json."
};
var outputOption = new Option<string?>("--output")
{
Description = "Write JSON payload to the specified file."
};
list.Add(policyOption);
list.Add(sbomOption);
list.Add(statusOption);
list.Add(severityOption);
list.Add(sinceOption);
list.Add(cursorOption);
list.Add(pageOption);
list.Add(pageSizeOption);
list.Add(formatOption);
list.Add(outputOption);
list.SetAction((parseResult, _) =>
{
var policy = parseResult.GetValue(policyOption) ?? string.Empty;
var sboms = parseResult.GetValue(sbomOption) ?? Array.Empty<string>();
var statuses = parseResult.GetValue(statusOption) ?? Array.Empty<string>();
var severities = parseResult.GetValue(severityOption) ?? Array.Empty<string>();
var since = parseResult.GetValue(sinceOption);
var cursor = parseResult.GetValue(cursorOption);
var page = parseResult.GetValue(pageOption);
var pageSize = parseResult.GetValue(pageSizeOption);
var selectedFormat = parseResult.GetValue(formatOption);
var output = parseResult.GetValue(outputOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandlePolicyFindingsListAsync(
services,
policy,
sboms,
statuses,
severities,
since,
cursor,
page,
pageSize,
selectedFormat,
output,
verbose,
cancellationToken);
});
var get = new Command("get", "Retrieve a specific finding.");
var findingArgument = new Argument<string>("finding-id")
{
Description = "Finding identifier (e.g. P-7:S-42:pkg:...)."
};
var getPolicyOption = new Option<string>("--policy")
{
Description = "Policy identifier for the finding.",
Required = true
};
var getFormatOption = new Option<string?>("--format")
{
Description = "Output format: table or json."
};
var getOutputOption = new Option<string?>("--output")
{
Description = "Write JSON payload to the specified file."
};
get.Add(findingArgument);
get.Add(getPolicyOption);
get.Add(getFormatOption);
get.Add(getOutputOption);
get.SetAction((parseResult, _) =>
{
var policy = parseResult.GetValue(getPolicyOption) ?? string.Empty;
var finding = parseResult.GetValue(findingArgument) ?? string.Empty;
var selectedFormat = parseResult.GetValue(getFormatOption);
var output = parseResult.GetValue(getOutputOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandlePolicyFindingsGetAsync(
services,
policy,
finding,
selectedFormat,
output,
verbose,
cancellationToken);
});
var explain = new Command("explain", "Fetch explain trace for a finding.");
var explainFindingArgument = new Argument<string>("finding-id")
{
Description = "Finding identifier."
};
var explainPolicyOption = new Option<string>("--policy")
{
Description = "Policy identifier.",
Required = true
};
var modeOption = new Option<string?>("--mode")
{
Description = "Explain mode (for example: verbose)."
};
var explainFormatOption = new Option<string?>("--format")
{
Description = "Output format: table or json."
};
var explainOutputOption = new Option<string?>("--output")
{
Description = "Write JSON payload to the specified file."
};
explain.Add(explainFindingArgument);
explain.Add(explainPolicyOption);
explain.Add(modeOption);
explain.Add(explainFormatOption);
explain.Add(explainOutputOption);
explain.SetAction((parseResult, _) =>
{
var policy = parseResult.GetValue(explainPolicyOption) ?? string.Empty;
var finding = parseResult.GetValue(explainFindingArgument) ?? string.Empty;
var mode = parseResult.GetValue(modeOption);
var selectedFormat = parseResult.GetValue(explainFormatOption);
var output = parseResult.GetValue(explainOutputOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandlePolicyFindingsExplainAsync(
services,
policy,
finding,
mode,
selectedFormat,
output,
verbose,
cancellationToken);
});
findings.Add(list);
findings.Add(get);
findings.Add(explain);
return findings;
}
private static Command BuildVulnCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
{
var vuln = new Command("vuln", "Explore vulnerability observations and overlays.");
@@ -531,12 +890,22 @@ internal static class CommandFactory
{
Description = "Emit raw JSON payload instead of a table."
};
var limitOption = new Option<int?>("--limit")
{
Description = "Maximum number of observations to return (default 200, max 500)."
};
var cursorOption = new Option<string?>("--cursor")
{
Description = "Opaque cursor token returned by a previous page."
};
observations.Add(tenantOption);
observations.Add(observationIdOption);
observations.Add(aliasOption);
observations.Add(purlOption);
observations.Add(cpeOption);
observations.Add(limitOption);
observations.Add(cursorOption);
observations.Add(jsonOption);
observations.SetAction((parseResult, _) =>
@@ -546,6 +915,8 @@ internal static class CommandFactory
var aliases = parseResult.GetValue(aliasOption) ?? Array.Empty<string>();
var purls = parseResult.GetValue(purlOption) ?? Array.Empty<string>();
var cpes = parseResult.GetValue(cpeOption) ?? Array.Empty<string>();
var limit = parseResult.GetValue(limitOption);
var cursor = parseResult.GetValue(cursorOption);
var emitJson = parseResult.GetValue(jsonOption);
var verbose = parseResult.GetValue(verboseOption);
@@ -556,6 +927,8 @@ internal static class CommandFactory
aliases,
purls,
cpes,
limit,
cursor,
emitJson,
verbose,
cancellationToken);

File diff suppressed because it is too large Load Diff

View File

@@ -1,34 +1,58 @@
using System;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Cli.Configuration;
internal static class AuthorityTokenUtilities
{
public static string ResolveScope(StellaOpsCliOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var scope = options.Authority?.Scope;
return string.IsNullOrWhiteSpace(scope)
? StellaOpsScopes.ConcelierJobsTrigger
: scope.Trim();
}
public static string BuildCacheKey(StellaOpsCliOptions options)
{
ArgumentNullException.ThrowIfNull(options);
if (options.Authority is null)
{
return string.Empty;
}
var scope = ResolveScope(options);
var credential = !string.IsNullOrWhiteSpace(options.Authority.Username)
? $"user:{options.Authority.Username}"
: $"client:{options.Authority.ClientId}";
return $"{options.Authority.Url}|{credential}|{scope}";
}
}
using System;
using System.Security.Cryptography;
using System.Text;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Cli.Configuration;
internal static class AuthorityTokenUtilities
{
public static string ResolveScope(StellaOpsCliOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var scope = options.Authority?.Scope;
return string.IsNullOrWhiteSpace(scope)
? StellaOpsScopes.ConcelierJobsTrigger
: scope.Trim();
}
public static string BuildCacheKey(StellaOpsCliOptions options)
{
ArgumentNullException.ThrowIfNull(options);
if (options.Authority is null)
{
return string.Empty;
}
var scope = ResolveScope(options);
var credential = !string.IsNullOrWhiteSpace(options.Authority.Username)
? $"user:{options.Authority.Username}"
: $"client:{options.Authority.ClientId}";
var cacheKey = $"{options.Authority.Url}|{credential}|{scope}";
if (!string.IsNullOrWhiteSpace(scope) && scope.Contains("orch:operate", StringComparison.OrdinalIgnoreCase))
{
var reasonHash = HashOperatorMetadata(options.Authority.OperatorReason);
var ticketHash = HashOperatorMetadata(options.Authority.OperatorTicket);
cacheKey = $"{cacheKey}|op_reason:{reasonHash}|op_ticket:{ticketHash}";
}
return cacheKey;
}
private static string HashOperatorMetadata(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "none";
}
var trimmed = value.Trim();
var bytes = Encoding.UTF8.GetBytes(trimmed);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -6,35 +6,35 @@ using System.Linq;
using Microsoft.Extensions.Configuration;
using StellaOps.Configuration;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Cli.Configuration;
public static class CliBootstrapper
{
public static (StellaOpsCliOptions Options, IConfigurationRoot Configuration) Build(string[] args)
{
var bootstrap = StellaOpsConfigurationBootstrapper.Build<StellaOpsCliOptions>(options =>
{
options.BindingSection = "StellaOps";
options.ConfigureBuilder = builder =>
{
if (args.Length > 0)
{
builder.AddCommandLine(args);
}
};
options.PostBind = (cliOptions, configuration) =>
{
namespace StellaOps.Cli.Configuration;
public static class CliBootstrapper
{
public static (StellaOpsCliOptions Options, IConfigurationRoot Configuration) Build(string[] args)
{
var bootstrap = StellaOpsConfigurationBootstrapper.Build<StellaOpsCliOptions>(options =>
{
options.BindingSection = "StellaOps";
options.ConfigureBuilder = builder =>
{
if (args.Length > 0)
{
builder.AddCommandLine(args);
}
};
options.PostBind = (cliOptions, configuration) =>
{
cliOptions.ApiKey = ResolveWithFallback(cliOptions.ApiKey, configuration, "API_KEY", "StellaOps:ApiKey", "ApiKey");
cliOptions.BackendUrl = ResolveWithFallback(cliOptions.BackendUrl, configuration, "STELLAOPS_BACKEND_URL", "StellaOps:BackendUrl", "BackendUrl");
cliOptions.ConcelierUrl = ResolveWithFallback(cliOptions.ConcelierUrl, configuration, "STELLAOPS_CONCELIER_URL", "StellaOps:ConcelierUrl", "ConcelierUrl");
cliOptions.ScannerSignaturePublicKeyPath = ResolveWithFallback(cliOptions.ScannerSignaturePublicKeyPath, configuration, "SCANNER_PUBLIC_KEY", "STELLAOPS_SCANNER_PUBLIC_KEY", "StellaOps:ScannerSignaturePublicKeyPath", "ScannerSignaturePublicKeyPath");
cliOptions.ApiKey = cliOptions.ApiKey?.Trim() ?? string.Empty;
cliOptions.BackendUrl = cliOptions.BackendUrl?.Trim() ?? string.Empty;
cliOptions.ConcelierUrl = cliOptions.ConcelierUrl?.Trim() ?? string.Empty;
cliOptions.ScannerSignaturePublicKeyPath = cliOptions.ScannerSignaturePublicKeyPath?.Trim() ?? string.Empty;
cliOptions.ScannerSignaturePublicKeyPath = cliOptions.ScannerSignaturePublicKeyPath?.Trim() ?? string.Empty;
var attemptsRaw = ResolveWithFallback(
string.Empty,
configuration,
@@ -42,17 +42,17 @@ public static class CliBootstrapper
"STELLAOPS_SCANNER_DOWNLOAD_ATTEMPTS",
"StellaOps:ScannerDownloadAttempts",
"ScannerDownloadAttempts");
if (string.IsNullOrWhiteSpace(attemptsRaw))
{
attemptsRaw = cliOptions.ScannerDownloadAttempts.ToString(CultureInfo.InvariantCulture);
}
if (int.TryParse(attemptsRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedAttempts) && parsedAttempts > 0)
{
cliOptions.ScannerDownloadAttempts = parsedAttempts;
}
if (string.IsNullOrWhiteSpace(attemptsRaw))
{
attemptsRaw = cliOptions.ScannerDownloadAttempts.ToString(CultureInfo.InvariantCulture);
}
if (int.TryParse(attemptsRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedAttempts) && parsedAttempts > 0)
{
cliOptions.ScannerDownloadAttempts = parsedAttempts;
}
if (cliOptions.ScannerDownloadAttempts <= 0)
{
cliOptions.ScannerDownloadAttempts = 3;
@@ -104,6 +104,20 @@ public static class CliBootstrapper
"StellaOps:Authority:Scope",
"Authority:Scope");
authority.OperatorReason = ResolveWithFallback(
authority.OperatorReason,
configuration,
"STELLAOPS_ORCH_REASON",
"StellaOps:Authority:OperatorReason",
"Authority:OperatorReason");
authority.OperatorTicket = ResolveWithFallback(
authority.OperatorTicket,
configuration,
"STELLAOPS_ORCH_TICKET",
"StellaOps:Authority:OperatorTicket",
"Authority:OperatorTicket");
authority.TokenCacheDirectory = ResolveWithFallback(
authority.TokenCacheDirectory,
configuration,
@@ -117,6 +131,8 @@ public static class CliBootstrapper
authority.Username = authority.Username?.Trim() ?? string.Empty;
authority.Password = string.IsNullOrWhiteSpace(authority.Password) ? null : authority.Password.Trim();
authority.Scope = string.IsNullOrWhiteSpace(authority.Scope) ? StellaOpsScopes.ConcelierJobsTrigger : authority.Scope.Trim();
authority.OperatorReason = authority.OperatorReason?.Trim() ?? string.Empty;
authority.OperatorTicket = authority.OperatorTicket?.Trim() ?? string.Empty;
authority.Resilience ??= new StellaOpsCliAuthorityResilienceOptions();
authority.Resilience.RetryDelays ??= new List<TimeSpan>();

View File

@@ -13,12 +13,12 @@ public sealed class StellaOpsCliOptions
public string ConcelierUrl { get; set; } = string.Empty;
public string ScannerCacheDirectory { get; set; } = "scanners";
public string ResultsDirectory { get; set; } = "results";
public string DefaultRunner { get; set; } = "docker";
public string ScannerCacheDirectory { get; set; } = "scanners";
public string ResultsDirectory { get; set; } = "results";
public string DefaultRunner { get; set; } = "docker";
public string ScannerSignaturePublicKeyPath { get; set; } = string.Empty;
public int ScannerDownloadAttempts { get; set; } = 3;
@@ -46,6 +46,10 @@ public sealed class StellaOpsCliAuthorityOptions
public string Scope { get; set; } = StellaOpsScopes.ConcelierJobsTrigger;
public string OperatorReason { get; set; } = string.Empty;
public string OperatorTicket { get; set; } = string.Empty;
public string TokenCacheDirectory { get; set; } = string.Empty;
public StellaOpsCliAuthorityResilienceOptions Resilience { get; set; } = new();

View File

@@ -142,10 +142,11 @@ internal sealed class AuthorityRevocationClient : IAuthorityRevocationClient
options.Authority.Username,
options.Authority.Password!,
scope,
null,
cancellationToken).ConfigureAwait(false);
}
return await tokenClient!.RequestClientCredentialsTokenAsync(scope, cancellationToken).ConfigureAwait(false);
return await tokenClient!.RequestClientCredentialsTokenAsync(scope, null, cancellationToken).ConfigureAwait(false);
}
private void EnsureAuthorityConfigured()

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
@@ -91,6 +92,20 @@ internal sealed class ConcelierObservationsClient : IConcelierObservationsClient
AppendValues(builder, "purl", query.Purls);
AppendValues(builder, "cpe", query.Cpes);
if (query.Limit.HasValue && query.Limit.Value > 0)
{
builder.Append('&');
builder.Append("limit=");
builder.Append(query.Limit.Value.ToString(CultureInfo.InvariantCulture));
}
if (!string.IsNullOrWhiteSpace(query.Cursor))
{
builder.Append('&');
builder.Append("cursor=");
builder.Append(Uri.EscapeDataString(query.Cursor));
}
return builder.ToString();
static void AppendValues(StringBuilder builder, string name, IReadOnlyList<string> values)
@@ -181,11 +196,12 @@ internal sealed class ConcelierObservationsClient : IConcelierObservationsClient
options.Authority.Username,
options.Authority.Password!,
scope,
null,
cancellationToken).ConfigureAwait(false);
}
else
{
token = await tokenClient.RequestClientCredentialsTokenAsync(scope, cancellationToken).ConfigureAwait(false);
token = await tokenClient.RequestClientCredentialsTokenAsync(scope, null, cancellationToken).ConfigureAwait(false);
}
await tokenClient.CacheTokenAsync(cacheKey, token.ToCacheEntry(), cancellationToken).ConfigureAwait(false);

View File

@@ -25,11 +25,7 @@ internal interface IBackendOperationsClient
Task<PolicySimulationResult> SimulatePolicyAsync(string policyId, PolicySimulationInput input, CancellationToken cancellationToken);
Task<PolicyFindingsPage> GetPolicyFindingsAsync(string policyId, PolicyFindingsQuery query, CancellationToken cancellationToken);
Task<PolicyFinding> GetPolicyFindingAsync(string policyId, string findingId, CancellationToken cancellationToken);
Task<PolicyFindingExplain> GetPolicyFindingExplainAsync(string policyId, string findingId, bool verbose, CancellationToken cancellationToken);
Task<PolicyActivationResult> ActivatePolicyRevisionAsync(string policyId, int version, PolicyActivationRequest request, CancellationToken cancellationToken);
Task<OfflineKitDownloadResult> DownloadOfflineKitAsync(string? bundleId, string destinationDirectory, bool overwrite, bool resume, CancellationToken cancellationToken);
@@ -38,4 +34,12 @@ internal interface IBackendOperationsClient
Task<OfflineKitStatus> GetOfflineKitStatusAsync(CancellationToken cancellationToken);
Task<AocIngestDryRunResponse> ExecuteAocIngestDryRunAsync(AocIngestDryRunRequest request, CancellationToken cancellationToken);
Task<AocVerifyResponse> ExecuteAocVerifyAsync(AocVerifyRequest request, CancellationToken cancellationToken);
Task<PolicyFindingsPage> GetPolicyFindingsAsync(PolicyFindingsQuery query, CancellationToken cancellationToken);
Task<PolicyFindingDocument> GetPolicyFindingAsync(string policyId, string findingId, CancellationToken cancellationToken);
Task<PolicyFindingExplainResult> GetPolicyFindingExplainAsync(string policyId, string findingId, string? mode, CancellationToken cancellationToken);
}

View File

@@ -9,7 +9,9 @@ internal sealed record AdvisoryObservationsQuery(
IReadOnlyList<string> ObservationIds,
IReadOnlyList<string> Aliases,
IReadOnlyList<string> Purls,
IReadOnlyList<string> Cpes);
IReadOnlyList<string> Cpes,
int? Limit,
string? Cursor);
internal sealed class AdvisoryObservationsResponse
{
@@ -20,6 +22,12 @@ internal sealed class AdvisoryObservationsResponse
[JsonPropertyName("linkset")]
public AdvisoryObservationLinksetAggregate Linkset { get; init; } =
new();
[JsonPropertyName("nextCursor")]
public string? NextCursor { get; init; }
[JsonPropertyName("hasMore")]
public bool HasMore { get; init; }
}
internal sealed class AdvisoryObservationDocument

View File

@@ -0,0 +1,100 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models;
internal sealed class AocVerifyRequest
{
[JsonPropertyName("tenant")]
public string Tenant { get; init; } = string.Empty;
[JsonPropertyName("since")]
public string? Since { get; init; }
[JsonPropertyName("limit")]
public int? Limit { get; init; }
[JsonPropertyName("sources")]
public IReadOnlyList<string>? Sources { get; init; }
[JsonPropertyName("codes")]
public IReadOnlyList<string>? Codes { get; init; }
}
internal sealed class AocVerifyResponse
{
[JsonPropertyName("tenant")]
public string? Tenant { get; init; }
[JsonPropertyName("window")]
public AocVerifyWindow Window { get; init; } = new();
[JsonPropertyName("checked")]
public AocVerifyChecked Checked { get; init; } = new();
[JsonPropertyName("violations")]
public IReadOnlyList<AocVerifyViolation> Violations { get; init; } =
Array.Empty<AocVerifyViolation>();
[JsonPropertyName("metrics")]
public AocVerifyMetrics Metrics { get; init; } = new();
[JsonPropertyName("truncated")]
public bool? Truncated { get; init; }
}
internal sealed class AocVerifyWindow
{
[JsonPropertyName("from")]
public DateTimeOffset? From { get; init; }
[JsonPropertyName("to")]
public DateTimeOffset? To { get; init; }
}
internal sealed class AocVerifyChecked
{
[JsonPropertyName("advisories")]
public int Advisories { get; init; }
[JsonPropertyName("vex")]
public int Vex { get; init; }
}
internal sealed class AocVerifyViolation
{
[JsonPropertyName("code")]
public string Code { get; init; } = string.Empty;
[JsonPropertyName("count")]
public int Count { get; init; }
[JsonPropertyName("examples")]
public IReadOnlyList<AocVerifyViolationExample> Examples { get; init; } =
Array.Empty<AocVerifyViolationExample>();
}
internal sealed class AocVerifyViolationExample
{
[JsonPropertyName("source")]
public string? Source { get; init; }
[JsonPropertyName("documentId")]
public string? DocumentId { get; init; }
[JsonPropertyName("contentHash")]
public string? ContentHash { get; init; }
[JsonPropertyName("path")]
public string? Path { get; init; }
}
internal sealed class AocVerifyMetrics
{
[JsonPropertyName("ingestion_write_total")]
public int? IngestionWriteTotal { get; init; }
[JsonPropertyName("aoc_violation_total")]
public int? AocViolationTotal { get; init; }
}

View File

@@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Cli.Services.Models;
internal sealed record PolicyActivationRequest(
bool RunNow,
DateTimeOffset? ScheduledAt,
string? Priority,
bool Rollback,
string? IncidentId,
string? Comment);
internal sealed record PolicyActivationResult(
string Status,
PolicyActivationRevision Revision);
internal sealed record PolicyActivationRevision(
string PolicyId,
int Version,
string Status,
bool RequiresTwoPersonApproval,
DateTimeOffset CreatedAt,
DateTimeOffset? ActivatedAt,
IReadOnlyList<PolicyActivationApproval> Approvals);
internal sealed record PolicyActivationApproval(
string ActorId,
DateTimeOffset ApprovedAt,
string? Comment);

View File

@@ -4,6 +4,7 @@ using System.Collections.Generic;
namespace StellaOps.Cli.Services.Models;
internal sealed record PolicyFindingsQuery(
string PolicyId,
IReadOnlyList<string> SbomIds,
IReadOnlyList<string> Statuses,
IReadOnlyList<string> Severities,
@@ -13,34 +14,37 @@ internal sealed record PolicyFindingsQuery(
DateTimeOffset? Since);
internal sealed record PolicyFindingsPage(
IReadOnlyList<PolicyFinding> Items,
string? NextCursor);
IReadOnlyList<PolicyFindingDocument> Items,
string? NextCursor,
int? TotalCount);
internal sealed record PolicyFinding(
internal sealed record PolicyFindingDocument(
string FindingId,
string Status,
string? SeverityNormalized,
double? SeverityScore,
string? SbomId,
int? PolicyVersion,
DateTimeOffset? UpdatedAt,
bool? Quieted,
string? QuietedBy,
string? Environment,
string? VexStatementId,
PolicyFindingSeverity Severity,
string SbomId,
IReadOnlyList<string> AdvisoryIds,
IReadOnlyList<string> Tags,
string RawJson);
PolicyFindingVexMetadata? Vex,
int PolicyVersion,
DateTimeOffset UpdatedAt,
string? RunId);
internal sealed record PolicyFindingExplain(
internal sealed record PolicyFindingSeverity(string Normalized, double? Score);
internal sealed record PolicyFindingVexMetadata(string? WinningStatementId, string? Source, string? Status);
internal sealed record PolicyFindingExplainResult(
string FindingId,
int? PolicyVersion,
int PolicyVersion,
IReadOnlyList<PolicyFindingExplainStep> Steps,
IReadOnlyList<string> SealedHints,
string RawJson);
IReadOnlyList<PolicyFindingExplainHint> SealedHints);
internal sealed record PolicyFindingExplainStep(
string? Rule,
string Rule,
string? Status,
IReadOnlyDictionary<string, object?> Inputs,
string RawJson);
string? Action,
double? Score,
IReadOnlyDictionary<string, string> Inputs,
IReadOnlyDictionary<string, string>? Evidence);
internal sealed record PolicyFindingExplainHint(string Message);

View File

@@ -0,0 +1,52 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Cli.Services.Models.Transport;
internal sealed class PolicyActivationRequestDocument
{
public string? Comment { get; set; }
public bool? RunNow { get; set; }
public DateTimeOffset? ScheduledAt { get; set; }
public string? Priority { get; set; }
public bool? Rollback { get; set; }
public string? IncidentId { get; set; }
}
internal sealed class PolicyActivationResponseDocument
{
public string? Status { get; set; }
public PolicyActivationRevisionDocument? Revision { get; set; }
}
internal sealed class PolicyActivationRevisionDocument
{
public string? PackId { get; set; }
public int? Version { get; set; }
public string? Status { get; set; }
public bool? RequiresTwoPersonApproval { get; set; }
public DateTimeOffset? CreatedAt { get; set; }
public DateTimeOffset? ActivatedAt { get; set; }
public List<PolicyActivationApprovalDocument>? Approvals { get; set; }
}
internal sealed class PolicyActivationApprovalDocument
{
public string? ActorId { get; set; }
public DateTimeOffset? ApprovedAt { get; set; }
public string? Comment { get; set; }
}

View File

@@ -0,0 +1,82 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
namespace StellaOps.Cli.Services.Models.Transport;
internal sealed class PolicyFindingsResponseDocument
{
public List<PolicyFindingDocumentDocument>? Items { get; set; }
public string? NextCursor { get; set; }
public int? TotalCount { get; set; }
}
internal sealed class PolicyFindingDocumentDocument
{
public string? FindingId { get; set; }
public string? Status { get; set; }
public PolicyFindingSeverityDocument? Severity { get; set; }
public string? SbomId { get; set; }
public List<string>? AdvisoryIds { get; set; }
public PolicyFindingVexDocument? Vex { get; set; }
public int? PolicyVersion { get; set; }
public DateTimeOffset? UpdatedAt { get; set; }
public string? RunId { get; set; }
}
internal sealed class PolicyFindingSeverityDocument
{
public string? Normalized { get; set; }
public double? Score { get; set; }
}
internal sealed class PolicyFindingVexDocument
{
public string? WinningStatementId { get; set; }
public string? Source { get; set; }
public string? Status { get; set; }
}
internal sealed class PolicyFindingExplainResponseDocument
{
public string? FindingId { get; set; }
public int? PolicyVersion { get; set; }
public List<PolicyFindingExplainStepDocument>? Steps { get; set; }
public List<PolicyFindingExplainHintDocument>? SealedHints { get; set; }
}
internal sealed class PolicyFindingExplainStepDocument
{
public string? Rule { get; set; }
public string? Status { get; set; }
public string? Action { get; set; }
public double? Score { get; set; }
public Dictionary<string, JsonElement>? Inputs { get; set; }
public Dictionary<string, JsonElement>? Evidence { get; set; }
}
internal sealed class PolicyFindingExplainHintDocument
{
public string? Message { get; set; }
}

View File

@@ -1,14 +1,18 @@
# CLI Task Board — Epic 1: Aggregation-Only Contract
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| CLI-AOC-19-001 | DOING (2025-10-27) | DevEx/CLI Guild | CONCELIER-WEB-AOC-19-001, EXCITITOR-WEB-AOC-19-001 | Implement `stella sources ingest --dry-run` printing would-write payloads with forbidden field scan results and guard status. | Command displays diff-safe JSON, highlights forbidden fields, exits non-zero on guard violation, and has unit tests. |
| CLI-AOC-19-001 | DONE (2025-10-27) | DevEx/CLI Guild | CONCELIER-WEB-AOC-19-001, EXCITITOR-WEB-AOC-19-001 | Implement `stella sources ingest --dry-run` printing would-write payloads with forbidden field scan results and guard status. | Command displays diff-safe JSON, highlights forbidden fields, exits non-zero on guard violation, and has unit tests. |
> Docs ready (2025-10-26): Reference behaviour/spec in `docs/cli/cli-reference.md` §2 and AOC reference §5.
> 2025-10-27: CLI command scaffolded with backend client call, JSON/table output, gzip/base64 normalisation, and exit-code mapping. Awaiting Concelier dry-run endpoint + integration tests once backend lands.
> 2025-10-27: Progress paused before adding CLI unit tests; blocked on extending `StubBackendClient` + fixtures for `ExecuteAocIngestDryRunAsync` coverage.
| CLI-AOC-19-002 | TODO | DevEx/CLI Guild | CLI-AOC-19-001 | Add `stella aoc verify` command supporting `--since`/`--limit`, mapping `ERR_AOC_00x` to exit codes, with JSON/table output. | Command integrates with both services, exit codes documented, regression tests green. |
> 2025-10-27: Added stubbed ingest responses + unit tests covering success/violation paths, output writing, and exit-code mapping.
| CLI-AOC-19-002 | DONE (2025-10-27) | DevEx/CLI Guild | CLI-AOC-19-001 | Add `stella aoc verify` command supporting `--since`/`--limit`, mapping `ERR_AOC_00x` to exit codes, with JSON/table output. | Command integrates with both services, exit codes documented, regression tests green. |
> Docs ready (2025-10-26): CLI guide §3 covers options/exit codes; deployment doc `docs/deploy/containers.md` describes required verifier user.
| CLI-AOC-19-003 | TODO | Docs/CLI Guild | CLI-AOC-19-001, CLI-AOC-19-002 | Update CLI reference and quickstart docs to cover new commands, exit codes, and offline verification workflows. | Docs updated; examples recorded; release notes mention new commands. |
> 2025-10-27: CLI wiring in progress; backend client/command surface being added with table/JSON output.
> 2025-10-27: Added JSON/table Spectre output, integration tests for exit-code handling, CLI metrics, and updated quickstart/architecture docs to cover guard workflows.
| CLI-AOC-19-003 | DONE (2025-10-27) | Docs/CLI Guild | CLI-AOC-19-001, CLI-AOC-19-002 | Update CLI reference and quickstart docs to cover new commands, exit codes, and offline verification workflows. | Docs updated; examples recorded; release notes mention new commands. |
> Docs note (2025-10-26): `docs/cli/cli-reference.md` now describes both commands, exit codes, and offline usage—sync help text once implementation lands.
> 2025-10-27: CLI reference now reflects final summary fields/JSON schema, quickstart includes verification/dry-run workflows, and API reference tables list both `sources ingest --dry-run` and `aoc verify`.
## Policy Engine v2
@@ -19,8 +23,11 @@
> 2025-10-26: Scheduler Models expose canonical run/diff schemas (`src/StellaOps.Scheduler.Models/docs/SCHED-MODELS-20-001-POLICY-RUNS.md`). Schema exporter lives at `scripts/export-policy-schemas.sh`; wire schema validation once DevOps publishes artifacts (see DEVOPS-POLICY-20-004).
> 2025-10-27: DevOps pipeline now publishes `policy-schema-exports` artefacts per commit (see `.gitea/workflows/build-test-deploy.yml`); Slack `#policy-engine` alerts trigger on schema diffs. Pull the JSON from the CI artifact instead of committing local copies.
> 2025-10-27: CLI command supports table/JSON output, environment parsing, `--fail-on-diff`, and maps `ERR_POL_*` to exit codes; tested in `StellaOps.Cli.Tests` against stubbed backend.
| CLI-POLICY-20-003 | TODO | DevEx/CLI Guild, Docs Guild | CLI-POLICY-20-002, WEB-POLICY-20-003, DOCS-POLICY-20-006 | Extend `stella findings ls|get` commands for policy-filtered retrieval with pagination, severity filters, and explain output. | Commands stream paginated results; explain view renders rationale entries; docs/help updated; end-to-end tests cover filters. |
| CLI-POLICY-20-003 | DONE (2025-10-30) | DevEx/CLI Guild, Docs Guild | CLI-POLICY-20-002, WEB-POLICY-20-003, DOCS-POLICY-20-006 | Extend `stella findings ls|get` commands for policy-filtered retrieval with pagination, severity filters, and explain output. | Commands stream paginated results; explain view renders rationale entries; docs/help updated; end-to-end tests cover filters. |
> 2025-10-27: Work paused after stubbing backend parsing helpers; command wiring/tests still pending. Resume by finishing backend query serialization + CLI output paths.
> 2025-10-30: Resuming implementation; wiring backend query DTOs, CLI handlers, and tests for paginated policy-filtered findings.
> 2025-10-30: Implemented backend client + CLI command surface for policy findings list/get/explain, added telemetry, interactive/json output, file writes, and unit tests covering filters + explain traces.
> 2025-10-30: Pending POLICY-ENGINE-20-006 change-stream orchestration to validate live pagination/cursor behaviour once engine emits incremental updates.
## Graph Explorer v1
@@ -39,7 +46,8 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| CLI-POLICY-23-004 | TODO | DevEx/CLI Guild | WEB-POLICY-23-001 | Add `stella policy lint` command validating SPL files with compiler diagnostics; support JSON output. | Command returns lint diagnostics; exit codes documented; tests cover error scenarios. |
| CLI-POLICY-23-005 | TODO | DevEx/CLI Guild | WEB-POLICY-23-002 | Implement `stella policy activate` with scheduling window, approval enforcement, and summary output. | Activation command integrates with API, handles 2-person rule failures; tests cover success/error. |
| CLI-POLICY-23-005 | DOING (2025-10-28) | DevEx/CLI Guild | POLICY-GATEWAY-18-002..003, WEB-POLICY-23-002 | Implement `stella policy activate` with scheduling window, approval enforcement, and summary output. | Activation command integrates with API, handles 2-person rule failures; tests cover success/error. |
> 2025-10-28: CLI command implemented with gateway integration (`policy activate`), interactive summary output, retry-aware metrics, and exit codes (0 success, 75 pending second approval). Tests cover success/pending/error paths.
| CLI-POLICY-23-006 | TODO | DevEx/CLI Guild | WEB-POLICY-23-004 | Provide `stella policy history` and `stella policy explain` commands to pull run history and explanation trees. | Commands output JSON/table; integration tests with fixtures; docs updated. |
## Graph & Vuln Explorer v1
@@ -74,6 +82,8 @@
| CLI-POLICY-27-004 | TODO | DevEx/CLI Guild | REGISTRY-API-27-007, REGISTRY-API-27-008, AUTH-POLICY-27-002 | Add lifecycle commands for publish/promote/rollback/sign (`stella policy publish --sign`, `promote --env`, `rollback`) with attestation verification and canary arguments. | Commands enforce signing requirement, support dry-run, produce audit logs; integration tests cover promotion + rollback; documentation updated. |
> Docs dependency: `DOCS-POLICY-27-006` requires publish/promote/rollback CLI examples.
| CLI-POLICY-27-005 | TODO | DevEx/CLI Guild, Docs Guild | DOCS-CONSOLE-27-007, DOCS-POLICY-27-007 | Update CLI reference and samples for Policy Studio including JSON schemas, exit codes, and CI snippets. | CLI docs merged with screenshots/transcripts; parity matrix updated; acceptance tests ensure `--help` examples compile. |
| CLI-POLICY-27-006 | TODO | DevEx/CLI Guild | AUTH-POLICY-27-001, CLI-POLICY-27-001 | Update CLI policy profiles/help text to request the new Policy Studio scope family, surface ProblemDetails guidance for `invalid_scope`, and adjust regression tests for scope failures. | Default CLI profiles reference new scopes, `stella policy` commands emit updated guidance, automated tests cover missing-scope responses, and docs regenerated via `scripts/update-cli-docs.sh`. |
> Heads-up: Gateway/Authority now reject `policy:write`/`policy:submit` tokens; automation will fail until profiles switch to the new scope bundle.
## Vulnerability Explorer (Sprint 29)
@@ -108,7 +118,8 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| CLI-EXPORT-35-001 | TODO | DevEx/CLI Guild | WEB-EXPORT-35-001, AUTH-EXPORT-35-001 | Implement `stella export profiles|runs` list/show, `run create`, `run status`, and resumable download commands with manifest/provenance retrieval. | Commands respect viewer/operator scopes; downloads resume via range requests; integration tests cover filters and offline mode. |
| CLI-EXPORT-35-001 | BLOCKED (2025-10-29) | DevEx/CLI Guild | WEB-EXPORT-35-001, AUTH-EXPORT-35-001 | Implement `stella export profiles|runs` list/show, `run create`, `run status`, and resumable download commands with manifest/provenance retrieval. | Commands respect viewer/operator scopes; downloads resume via range requests; integration tests cover filters and offline mode. |
> Blocked: Gateway routing (`WEB-EXPORT-35-001`) and Authority scopes pending; CLI cannot hit Export APIs until those services land.
| CLI-EXPORT-36-001 | TODO | DevEx/CLI Guild | CLI-EXPORT-35-001, WEB-EXPORT-36-001 | Add distribution commands (`stella export distribute`, `run download --resume` enhancements) and improved status polling with progress bars. | Distribution commands push OCI/object storage; status polling handles SSE fallback; tests cover failure cases. |
| CLI-EXPORT-37-001 | TODO | DevEx/CLI Guild | CLI-EXPORT-36-001, WEB-EXPORT-37-001 | Provide scheduling (`stella export schedule`), retention, and `export verify` commands performing signature/hash validation. | Scheduling/retention commands enforce admin scopes; verify command checks signatures/hashes; examples documented; tests cover success/failure. |
## Orchestrator Dashboard (Epic 9)
@@ -123,8 +134,10 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| CLI-NOTIFY-38-001 | TODO | DevEx/CLI Guild | WEB-NOTIFY-38-001, AUTH-NOTIFY-38-001 | Implement `stella notify rules|templates|incidents` commands (list/create/update/test/ack) with file inputs, JSON output, and RBAC-aware flow. | Commands invoke notifier APIs successfully; rule test uses local events file; integration tests cover create/test/ack; help docs updated. |
| CLI-NOTIFY-39-001 | TODO | DevEx/CLI Guild | CLI-NOTIFY-38-001, WEB-NOTIFY-39-001 | Add simulation (`stella notify simulate`) and digest commands with diff output and schedule triggering, including dry-run mode. | Simulation command returns deterministic diff; digest command triggers run and polls status; tests cover filters and failures. |
| CLI-NOTIFY-38-001 | BLOCKED (2025-10-29) | DevEx/CLI Guild | WEB-NOTIFY-38-001, AUTH-NOTIFY-38-001 | Implement `stella notify rules|templates|incidents` commands (list/create/update/test/ack) with file inputs, JSON output, and RBAC-aware flow. | Commands invoke notifier APIs successfully; rule test uses local events file; integration tests cover create/test/ack; help docs updated. |
> Blocked: Gateway routing (`WEB-NOTIFY-38-001`) and Authority scopes (`AUTH-NOTIFY-38-001`) pending; CLI cannot exercise APIs until endpoints and token scopes are published.
| CLI-NOTIFY-39-001 | BLOCKED (2025-10-29) | DevEx/CLI Guild | CLI-NOTIFY-38-001, WEB-NOTIFY-39-001 | Add simulation (`stella notify simulate`) and digest commands with diff output and schedule triggering, including dry-run mode. | Simulation command returns deterministic diff; digest command triggers run and polls status; tests cover filters and failures. |
> Blocked: Foundation commands (`CLI-NOTIFY-38-001`) and gateway digest/simulation APIs (`WEB-NOTIFY-39-001`) not available yet.
| CLI-NOTIFY-40-001 | TODO | DevEx/CLI Guild | CLI-NOTIFY-39-001, WEB-NOTIFY-40-001 | Provide ack token redemption workflow, escalation management, localization previews, and channel health checks. | Ack redemption validates signed tokens; escalation commands manage schedules; localization preview shows variants; integration tests cover negative cases. |
## CLI Parity & Task Packs (Epic 12)

View File

@@ -13,7 +13,12 @@ internal static class CliMetrics
private static readonly Counter<long> OfflineKitDownloadCounter = Meter.CreateCounter<long>("stellaops.cli.offline.kit.download.count");
private static readonly Counter<long> OfflineKitImportCounter = Meter.CreateCounter<long>("stellaops.cli.offline.kit.import.count");
private static readonly Counter<long> PolicySimulationCounter = Meter.CreateCounter<long>("stellaops.cli.policy.simulate.count");
private static readonly Counter<long> PolicyActivationCounter = Meter.CreateCounter<long>("stellaops.cli.policy.activate.count");
private static readonly Counter<long> SourcesDryRunCounter = Meter.CreateCounter<long>("stellaops.cli.sources.dryrun.count");
private static readonly Counter<long> AocVerifyCounter = Meter.CreateCounter<long>("stellaops.cli.aoc.verify.count");
private static readonly Counter<long> PolicyFindingsListCounter = Meter.CreateCounter<long>("stellaops.cli.policy.findings.list.count");
private static readonly Counter<long> PolicyFindingsGetCounter = Meter.CreateCounter<long>("stellaops.cli.policy.findings.get.count");
private static readonly Counter<long> PolicyFindingsExplainCounter = Meter.CreateCounter<long>("stellaops.cli.policy.findings.explain.count");
private static readonly Histogram<double> CommandDurationHistogram = Meter.CreateHistogram<double>("stellaops.cli.command.duration.ms");
public static void RecordScannerDownload(string channel, bool fromCache)
@@ -52,12 +57,42 @@ internal static class CliMetrics
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)
});
public static void RecordPolicyActivation(string outcome)
=> PolicyActivationCounter.Add(1, new KeyValuePair<string, object?>[]
{
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)
});
public static void RecordSourcesDryRun(string status)
=> SourcesDryRunCounter.Add(1, new KeyValuePair<string, object?>[]
{
new("status", string.IsNullOrWhiteSpace(status) ? "unknown" : status)
});
public static void RecordAocVerify(string outcome)
=> AocVerifyCounter.Add(1, new KeyValuePair<string, object?>[]
{
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)
});
public static void RecordPolicyFindingsList(string outcome)
=> PolicyFindingsListCounter.Add(1, new KeyValuePair<string, object?>[]
{
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)
});
public static void RecordPolicyFindingsGet(string outcome)
=> PolicyFindingsGetCounter.Add(1, new KeyValuePair<string, object?>[]
{
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)
});
public static void RecordPolicyFindingsExplain(string outcome)
=> PolicyFindingsExplainCounter.Add(1, new KeyValuePair<string, object?>[]
{
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)
});
public static IDisposable MeasureCommandDuration(string command)
{
var start = DateTime.UtcNow;

View File

@@ -0,0 +1,254 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Mongo2Go;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Aoc;
using StellaOps.Concelier.Connector.Common.Fetch;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Core.Aoc;
using StellaOps.Concelier.Core.Linksets;
using StellaOps.Concelier.RawModels;
using StellaOps.Concelier.Storage.Mongo;
using StellaOps.Concelier.Storage.Mongo.Documents;
namespace StellaOps.Concelier.Connector.Common.Tests;
public sealed class SourceFetchServiceGuardTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner;
private readonly IMongoDatabase _database;
private readonly RawDocumentStorage _rawStorage;
public SourceFetchServiceGuardTests()
{
_runner = MongoDbRunner.Start(singleNodeReplSet: true);
var client = new MongoClient(_runner.ConnectionString);
_database = client.GetDatabase($"source-fetch-guard-{Guid.NewGuid():N}");
_rawStorage = new RawDocumentStorage(_database);
}
[Fact]
public async Task FetchAsync_ValidatesWithGuardBeforePersisting()
{
var responsePayload = "{\"id\":\"CVE-2025-1111\"}";
var handler = new StaticHttpMessageHandler(() => CreateSuccessResponse(responsePayload));
var client = new HttpClient(handler, disposeHandler: false);
var httpClientFactory = new StaticHttpClientFactory(client);
var documentStore = new RecordingDocumentStore();
var guard = new RecordingAdvisoryRawWriteGuard();
var jitter = new NoJitterSource();
var httpOptions = new TestOptionsMonitor<StellaOps.Concelier.Connector.Common.Http.SourceHttpClientOptions>(new StellaOps.Concelier.Connector.Common.Http.SourceHttpClientOptions());
var storageOptions = Options.Create(new MongoStorageOptions
{
ConnectionString = _runner.ConnectionString,
DatabaseName = _database.DatabaseNamespace.DatabaseName,
});
var linksetMapper = new NoopAdvisoryLinksetMapper();
var service = new SourceFetchService(
httpClientFactory,
_rawStorage,
documentStore,
NullLogger<SourceFetchService>.Instance,
jitter,
guard,
linksetMapper,
TimeProvider.System,
httpOptions,
storageOptions);
var request = new SourceFetchRequest("client", "vndr.msrc", new Uri("https://example.test/advisories/ADV-1234"))
{
Metadata = new Dictionary<string, string>
{
["upstream.id"] = "ADV-1234",
["content.format"] = "csaf",
["msrc.lastModified"] = DateTimeOffset.UtcNow.AddDays(-1).ToString("O"),
}
};
var result = await service.FetchAsync(request, CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.NotNull(guard.LastDocument);
Assert.Equal("tenant-default", guard.LastDocument!.Tenant);
Assert.Equal("msrc", guard.LastDocument.Source.Vendor);
Assert.Equal("ADV-1234", guard.LastDocument.Upstream.UpstreamId);
var expectedHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(responsePayload))).ToLowerInvariant();
Assert.Equal(expectedHash, guard.LastDocument.Upstream.ContentHash);
Assert.NotNull(documentStore.LastRecord);
Assert.True(documentStore.UpsertCount > 0);
Assert.Equal("msrc", documentStore.LastRecord!.Metadata!["source.vendor"]);
Assert.Equal("tenant-default", documentStore.LastRecord.Metadata!["tenant"]);
// verify raw payload stored
var filesCollection = _database.GetCollection<BsonDocument>("documents.files");
var count = await filesCollection.CountDocumentsAsync(FilterDefinition<BsonDocument>.Empty);
Assert.Equal(1, count);
}
[Fact]
public async Task FetchAsync_WhenGuardThrows_DoesNotPersist()
{
var handler = new StaticHttpMessageHandler(() => CreateSuccessResponse("{\"id\":\"CVE-2025-2222\"}"));
var client = new HttpClient(handler, disposeHandler: false);
var httpClientFactory = new StaticHttpClientFactory(client);
var documentStore = new RecordingDocumentStore();
var guard = new RecordingAdvisoryRawWriteGuard { ShouldThrow = true };
var jitter = new NoJitterSource();
var httpOptions = new TestOptionsMonitor<StellaOps.Concelier.Connector.Common.Http.SourceHttpClientOptions>(new StellaOps.Concelier.Connector.Common.Http.SourceHttpClientOptions());
var storageOptions = Options.Create(new MongoStorageOptions
{
ConnectionString = _runner.ConnectionString,
DatabaseName = _database.DatabaseNamespace.DatabaseName,
});
var linksetMapper = new NoopAdvisoryLinksetMapper();
var service = new SourceFetchService(
httpClientFactory,
_rawStorage,
documentStore,
NullLogger<SourceFetchService>.Instance,
jitter,
guard,
linksetMapper,
TimeProvider.System,
httpOptions,
storageOptions);
var request = new SourceFetchRequest("client", "nvd", new Uri("https://example.test/data/XYZ"))
{
Metadata = new Dictionary<string, string>
{
["vulnerability.id"] = "CVE-2025-2222",
}
};
await Assert.ThrowsAsync<ConcelierAocGuardException>(() => service.FetchAsync(request, CancellationToken.None));
Assert.Equal(0, documentStore.UpsertCount);
var filesCollection = _database.GetCollection<BsonDocument>("documents.files");
var count = await filesCollection.CountDocumentsAsync(FilterDefinition<BsonDocument>.Empty);
Assert.Equal(0, count);
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
private static HttpResponseMessage CreateSuccessResponse(string payload)
{
var message = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
};
message.Headers.ETag = new EntityTagHeaderValue("\"etag\"");
message.Content.Headers.LastModified = DateTimeOffset.UtcNow.AddHours(-1);
return message;
}
private sealed class StaticHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public StaticHttpClientFactory(HttpClient client) => _client = client;
public HttpClient CreateClient(string name) => _client;
}
private sealed class StaticHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpResponseMessage> _responseFactory;
public StaticHttpMessageHandler(Func<HttpResponseMessage> responseFactory) => _responseFactory = responseFactory;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(_responseFactory());
}
private sealed class RecordingDocumentStore : IDocumentStore
{
public DocumentRecord? LastRecord { get; private set; }
public int UpsertCount { get; private set; }
public Task<DocumentRecord> UpsertAsync(DocumentRecord record, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
UpsertCount++;
LastRecord = record;
return Task.FromResult(record);
}
public Task<DocumentRecord?> FindBySourceAndUriAsync(string sourceName, string uri, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> Task.FromResult<DocumentRecord?>(null);
public Task<DocumentRecord?> FindAsync(Guid id, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> Task.FromResult<DocumentRecord?>(null);
public Task<bool> UpdateStatusAsync(Guid id, string status, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> Task.FromResult(false);
}
private sealed class RecordingAdvisoryRawWriteGuard : IAdvisoryRawWriteGuard
{
public AdvisoryRawDocument? LastDocument { get; private set; }
public bool ShouldThrow { get; set; }
public void EnsureValid(AdvisoryRawDocument document)
{
LastDocument = document;
if (ShouldThrow)
{
var violation = AocViolation.Create(AocViolationCode.InvalidTenant, "/tenant", "test");
throw new ConcelierAocGuardException(AocGuardResult.FromViolations(new[] { violation }));
}
}
}
private sealed class NoJitterSource : IJitterSource
{
public TimeSpan Next(TimeSpan minInclusive, TimeSpan maxInclusive) => minInclusive;
}
private sealed class TestOptionsMonitor<T> : IOptionsMonitor<T>
where T : class, new()
{
private readonly T _options;
public TestOptionsMonitor(T options) => _options = options;
public T CurrentValue => _options;
public T Get(string? name) => _options;
public IDisposable OnChange(Action<T, string> listener) => NullDisposable.Instance;
private sealed class NullDisposable : IDisposable
{
public static NullDisposable Instance { get; } = new();
public void Dispose() { }
}
}
private sealed class NoopAdvisoryLinksetMapper : IAdvisoryLinksetMapper
{
public RawLinkset Map(AdvisoryRawDocument document) => new();
}
}

View File

@@ -195,9 +195,20 @@ public sealed class SourceHttpClientBuilderTests
var configuredOptions = monitor.Get("source.nkcki");
Assert.False(configuredOptions.AllowInvalidServerCertificates);
Assert.Contains(
configuredOptions.TrustedRootCertificates,
certificate => string.Equals(certificate.Thumbprint, trustedRoot.Thumbprint, StringComparison.OrdinalIgnoreCase));
Assert.NotEmpty(configuredOptions.TrustedRootCertificates);
using (var manualChain = new X509Chain())
{
manualChain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
manualChain.ChainPolicy.CustomTrustStore.AddRange(configuredOptions.TrustedRootCertificates.ToArray());
manualChain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
manualChain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
#pragma warning disable SYSLIB0057
using var manualServerCertificate = new X509Certificate2(trustedRoot.Export(X509ContentType.Cert));
#pragma warning restore SYSLIB0057
Assert.True(manualChain.Build(manualServerCertificate));
}
Assert.All(configuredOptions.TrustedRootCertificates, certificate => Assert.NotEqual(IntPtr.Zero, certificate.Handle));
Assert.NotNull(capturedHandler);
var callback = capturedHandler!.SslOptions.RemoteCertificateValidationCallback;
@@ -205,7 +216,12 @@ public sealed class SourceHttpClientBuilderTests
#pragma warning disable SYSLIB0057
using var serverCertificate = new X509Certificate2(trustedRoot.Export(X509ContentType.Cert));
#pragma warning restore SYSLIB0057
var validationResult = callback!(new object(), serverCertificate, null, SslPolicyErrors.RemoteCertificateChainErrors);
using var chain = new X509Chain();
chain.ChainPolicy.CustomTrustStore.Add(serverCertificate);
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
_ = chain.Build(serverCertificate);
var validationResult = callback!(new object(), serverCertificate, chain, SslPolicyErrors.RemoteCertificateChainErrors);
Assert.True(validationResult);
Directory.Delete(offlineRoot.FullName, recursive: true);
@@ -246,9 +262,20 @@ public sealed class SourceHttpClientBuilderTests
var configuredOptions = provider.GetRequiredService<IOptionsMonitor<SourceHttpClientOptions>>().Get("source.nkcki");
Assert.False(configuredOptions.AllowInvalidServerCertificates);
Assert.Contains(
configuredOptions.TrustedRootCertificates,
certificate => string.Equals(certificate.Thumbprint, trustedRoot.Thumbprint, StringComparison.OrdinalIgnoreCase));
Assert.NotEmpty(configuredOptions.TrustedRootCertificates);
using (var manualChain = new X509Chain())
{
manualChain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
manualChain.ChainPolicy.CustomTrustStore.AddRange(configuredOptions.TrustedRootCertificates.ToArray());
manualChain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
manualChain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
#pragma warning disable SYSLIB0057
using var manualServerCertificate = new X509Certificate2(trustedRoot.Export(X509ContentType.Cert));
#pragma warning restore SYSLIB0057
Assert.True(manualChain.Build(manualServerCertificate));
}
Assert.All(configuredOptions.TrustedRootCertificates, certificate => Assert.NotEqual(IntPtr.Zero, certificate.Handle));
Assert.NotNull(capturedHandler);
var callback = capturedHandler!.SslOptions.RemoteCertificateValidationCallback;
@@ -256,7 +283,12 @@ public sealed class SourceHttpClientBuilderTests
#pragma warning disable SYSLIB0057
using var serverCertificate = new X509Certificate2(trustedRoot.Export(X509ContentType.Cert));
#pragma warning restore SYSLIB0057
var validationResult = callback!(new object(), serverCertificate, null, SslPolicyErrors.RemoteCertificateChainErrors);
using var chain = new X509Chain();
chain.ChainPolicy.CustomTrustStore.Add(serverCertificate);
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
_ = chain.Build(serverCertificate);
var validationResult = callback!(new object(), serverCertificate, chain, SslPolicyErrors.RemoteCertificateChainErrors);
Assert.True(validationResult);
Directory.Delete(offlineRoot.FullName, recursive: true);

View File

@@ -1,20 +1,25 @@
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Common.Telemetry;
using StellaOps.Concelier.Storage.Mongo;
using StellaOps.Concelier.Storage.Mongo.Documents;
namespace StellaOps.Concelier.Connector.Common.Fetch;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Common.Telemetry;
using StellaOps.Concelier.Core.Aoc;
using StellaOps.Concelier.Core.Linksets;
using StellaOps.Concelier.RawModels;
using StellaOps.Concelier.Storage.Mongo;
using StellaOps.Concelier.Storage.Mongo.Documents;
using System.Text.Json;
namespace StellaOps.Concelier.Connector.Common.Fetch;
/// <summary>
/// Executes HTTP fetches for connectors, capturing raw responses with metadata for downstream stages.
@@ -26,31 +31,39 @@ public sealed class SourceFetchService
private readonly IHttpClientFactory _httpClientFactory;
private readonly RawDocumentStorage _rawDocumentStorage;
private readonly IDocumentStore _documentStore;
private readonly ILogger<SourceFetchService> _logger;
private readonly TimeProvider _timeProvider;
private readonly IOptionsMonitor<SourceHttpClientOptions> _httpClientOptions;
private readonly IOptions<MongoStorageOptions> _storageOptions;
private readonly IJitterSource _jitterSource;
private readonly ILogger<SourceFetchService> _logger;
private readonly TimeProvider _timeProvider;
private readonly IOptionsMonitor<SourceHttpClientOptions> _httpClientOptions;
private readonly IOptions<MongoStorageOptions> _storageOptions;
private readonly IJitterSource _jitterSource;
private readonly IAdvisoryRawWriteGuard _guard;
private readonly IAdvisoryLinksetMapper _linksetMapper;
private readonly string _connectorVersion;
public SourceFetchService(
IHttpClientFactory httpClientFactory,
RawDocumentStorage rawDocumentStorage,
IDocumentStore documentStore,
ILogger<SourceFetchService> logger,
IJitterSource jitterSource,
TimeProvider? timeProvider = null,
IOptionsMonitor<SourceHttpClientOptions>? httpClientOptions = null,
IOptions<MongoStorageOptions>? storageOptions = null)
{
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_rawDocumentStorage = rawDocumentStorage ?? throw new ArgumentNullException(nameof(rawDocumentStorage));
_documentStore = documentStore ?? throw new ArgumentNullException(nameof(documentStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_jitterSource = jitterSource ?? throw new ArgumentNullException(nameof(jitterSource));
_timeProvider = timeProvider ?? TimeProvider.System;
_httpClientOptions = httpClientOptions ?? throw new ArgumentNullException(nameof(httpClientOptions));
_storageOptions = storageOptions ?? throw new ArgumentNullException(nameof(storageOptions));
}
IJitterSource jitterSource,
IAdvisoryRawWriteGuard guard,
IAdvisoryLinksetMapper linksetMapper,
TimeProvider? timeProvider = null,
IOptionsMonitor<SourceHttpClientOptions>? httpClientOptions = null,
IOptions<MongoStorageOptions>? storageOptions = null)
{
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_rawDocumentStorage = rawDocumentStorage ?? throw new ArgumentNullException(nameof(rawDocumentStorage));
_documentStore = documentStore ?? throw new ArgumentNullException(nameof(documentStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_jitterSource = jitterSource ?? throw new ArgumentNullException(nameof(jitterSource));
_guard = guard ?? throw new ArgumentNullException(nameof(guard));
_linksetMapper = linksetMapper ?? throw new ArgumentNullException(nameof(linksetMapper));
_timeProvider = timeProvider ?? TimeProvider.System;
_httpClientOptions = httpClientOptions ?? throw new ArgumentNullException(nameof(httpClientOptions));
_storageOptions = storageOptions ?? throw new ArgumentNullException(nameof(storageOptions));
_connectorVersion = typeof(SourceFetchService).Assembly.GetName().Version?.ToString() ?? "0.0.0";
}
public async Task<SourceFetchResult> FetchAsync(SourceFetchRequest request, CancellationToken cancellationToken)
{
@@ -88,44 +101,55 @@ public sealed class SourceFetchService
throw new HttpRequestException($"Fetch failed with status {(int)response.StatusCode} {response.StatusCode} from {request.RequestUri}. Body preview: {body}");
}
var contentBytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
var sha256 = Convert.ToHexString(SHA256.HashData(contentBytes)).ToLowerInvariant();
var fetchedAt = _timeProvider.GetUtcNow();
var contentType = response.Content.Headers.ContentType?.ToString();
var storageOptions = _storageOptions.Value;
var retention = storageOptions.RawDocumentRetention;
DateTimeOffset? expiresAt = null;
if (retention > TimeSpan.Zero)
{
var grace = storageOptions.RawDocumentRetentionTtlGrace >= TimeSpan.Zero
? storageOptions.RawDocumentRetentionTtlGrace
: TimeSpan.Zero;
try
{
expiresAt = fetchedAt.Add(retention).Add(grace);
}
catch (ArgumentOutOfRangeException)
{
expiresAt = DateTimeOffset.MaxValue;
}
}
var gridFsId = await _rawDocumentStorage.UploadAsync(
request.SourceName,
request.RequestUri.ToString(),
contentBytes,
contentType,
expiresAt,
cancellationToken).ConfigureAwait(false);
var contentBytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
var contentHash = Convert.ToHexString(SHA256.HashData(contentBytes)).ToLowerInvariant();
var fetchedAt = _timeProvider.GetUtcNow();
var contentType = response.Content.Headers.ContentType?.ToString();
var headers = CreateHeaderDictionary(response);
var metadata = request.Metadata is null
? new Dictionary<string, string>(StringComparer.Ordinal)
: new Dictionary<string, string>(request.Metadata, StringComparer.Ordinal);
metadata["attempts"] = sendResult.Attempts.ToString(CultureInfo.InvariantCulture);
metadata["fetchedAt"] = fetchedAt.ToString("O");
var metadata = request.Metadata is null
? new Dictionary<string, string>(StringComparer.Ordinal)
: new Dictionary<string, string>(request.Metadata, StringComparer.Ordinal);
metadata["attempts"] = sendResult.Attempts.ToString(CultureInfo.InvariantCulture);
metadata["fetchedAt"] = fetchedAt.ToString("O");
var guardDocument = CreateRawAdvisoryDocument(
request,
response,
contentBytes,
contentHash,
metadata,
headers,
fetchedAt);
_guard.EnsureValid(guardDocument);
var storageOptions = _storageOptions.Value;
var retention = storageOptions.RawDocumentRetention;
DateTimeOffset? expiresAt = null;
if (retention > TimeSpan.Zero)
{
var grace = storageOptions.RawDocumentRetentionTtlGrace >= TimeSpan.Zero
? storageOptions.RawDocumentRetentionTtlGrace
: TimeSpan.Zero;
try
{
expiresAt = fetchedAt.Add(retention).Add(grace);
}
catch (ArgumentOutOfRangeException)
{
expiresAt = DateTimeOffset.MaxValue;
}
}
var gridFsId = await _rawDocumentStorage.UploadAsync(
request.SourceName,
request.RequestUri.ToString(),
contentBytes,
contentType,
expiresAt,
cancellationToken).ConfigureAwait(false);
var existing = await _documentStore.FindBySourceAndUriAsync(request.SourceName, request.RequestUri.ToString(), cancellationToken).ConfigureAwait(false);
var recordId = existing?.Id ?? Guid.NewGuid();
@@ -133,11 +157,11 @@ public sealed class SourceFetchService
var record = new DocumentRecord(
recordId,
request.SourceName,
request.RequestUri.ToString(),
fetchedAt,
sha256,
DocumentStatuses.PendingParse,
contentType,
request.RequestUri.ToString(),
fetchedAt,
contentHash,
DocumentStatuses.PendingParse,
contentType,
headers,
metadata,
response.Headers.ETag?.Tag,
@@ -148,7 +172,7 @@ public sealed class SourceFetchService
var upserted = await _documentStore.UpsertAsync(record, cancellationToken).ConfigureAwait(false);
SourceDiagnostics.RecordHttpRequest(request.SourceName, request.ClientName, response.StatusCode, sendResult.Attempts, duration, contentBytes.LongLength, rateLimitRemaining);
activity?.SetStatus(ActivityStatusCode.Ok);
_logger.LogInformation("Fetched {Source} document {Uri} (sha256={Sha})", request.SourceName, request.RequestUri, sha256);
_logger.LogInformation("Fetched {Source} document {Uri} (sha256={Sha})", request.SourceName, request.RequestUri, contentHash);
return SourceFetchResult.Success(upserted, response.StatusCode);
}
}
@@ -156,9 +180,373 @@ public sealed class SourceFetchService
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
throw;
}
}
}
}
private AdvisoryRawDocument CreateRawAdvisoryDocument(
SourceFetchRequest request,
HttpResponseMessage response,
byte[] contentBytes,
string contentHash,
IDictionary<string, string> metadata,
IDictionary<string, string> headers,
DateTimeOffset fetchedAt)
{
var tenant = _storageOptions.Value.DefaultTenant;
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var pair in metadata)
{
if (!string.IsNullOrWhiteSpace(pair.Key) && pair.Value is not null)
{
metadataBuilder[pair.Key] = pair.Value;
}
}
using var jsonDocument = ParseContent(request, contentBytes);
var metadataSnapshot = metadataBuilder.ToImmutable();
var stream = ResolveStream(metadataSnapshot, response, request);
if (!string.IsNullOrWhiteSpace(stream))
{
metadataBuilder["source.stream"] = stream!;
metadataSnapshot = metadataBuilder.ToImmutable();
}
var vendor = ResolveVendor(request.SourceName, metadataSnapshot);
metadataBuilder["source.vendor"] = vendor;
metadataBuilder["source.connector_version"] = _connectorVersion;
metadataSnapshot = metadataBuilder.ToImmutable();
var headerSnapshot = headers.ToImmutableDictionary(
static pair => pair.Key,
static pair => pair.Value,
StringComparer.Ordinal);
var provenance = BuildProvenance(request, response, metadataSnapshot, headerSnapshot, fetchedAt, contentHash);
var upstreamId = ResolveUpstreamId(metadataSnapshot, request);
var documentVersion = ResolveDocumentVersion(metadataSnapshot, response, fetchedAt);
var signature = CreateSignatureMetadata(metadataSnapshot);
var aliases = upstreamId is null
? ImmutableArray<string>.Empty
: ImmutableArray.Create(upstreamId);
var identifiers = new RawIdentifiers(aliases, upstreamId ?? contentHash);
var contentFormat = ResolveFormat(metadataSnapshot, response);
var specVersion = GetMetadataValue(metadataSnapshot, "content.specVersion", "content.spec_version");
var encoding = response.Content.Headers.ContentType?.CharSet;
var content = new RawContent(
contentFormat,
specVersion,
jsonDocument.RootElement.Clone(),
encoding);
var source = new RawSourceMetadata(
vendor,
request.SourceName,
_connectorVersion,
stream);
var upstream = new RawUpstreamMetadata(
upstreamId ?? request.RequestUri.ToString(),
documentVersion,
fetchedAt,
contentHash,
signature,
provenance);
var supersedes = GetMetadataValue(metadataSnapshot, "supersedes");
var rawDocument = new AdvisoryRawDocument(
tenant,
source,
upstream,
content,
identifiers,
new RawLinkset
{
Aliases = aliases,
PackageUrls = ImmutableArray<string>.Empty,
Cpes = ImmutableArray<string>.Empty,
References = ImmutableArray<RawReference>.Empty,
ReconciledFrom = ImmutableArray<string>.Empty,
Notes = ImmutableDictionary<string, string>.Empty
},
supersedes);
var mappedLinkset = _linksetMapper.Map(rawDocument);
rawDocument = rawDocument with { Linkset = mappedLinkset };
ApplyRawDocumentMetadata(metadata, rawDocument);
return rawDocument;
}
private static JsonDocument ParseContent(SourceFetchRequest request, byte[] contentBytes)
{
if (contentBytes is null || contentBytes.Length == 0)
{
throw new InvalidOperationException($"Source {request.SourceName} returned an empty payload for {request.RequestUri}.");
}
try
{
return JsonDocument.Parse(contentBytes);
}
catch (JsonException ex)
{
throw new InvalidOperationException($"Raw advisory payload from {request.SourceName} is not valid JSON ({request.RequestUri}).", ex);
}
}
private static ImmutableDictionary<string, string> BuildProvenance(
SourceFetchRequest request,
HttpResponseMessage response,
ImmutableDictionary<string, string> metadata,
ImmutableDictionary<string, string> headers,
DateTimeOffset fetchedAt,
string contentHash)
{
var builder = metadata.ToBuilder();
foreach (var header in headers)
{
var key = $"http.header.{header.Key.Trim().ToLowerInvariant()}";
builder[key] = header.Value;
}
builder["http.status_code"] = ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture);
builder["http.method"] = request.Method.Method;
builder["http.uri"] = request.RequestUri.ToString();
if (response.Headers.ETag?.Tag is { } etag)
{
builder["http.etag"] = etag;
}
if (response.Content.Headers.LastModified is { } lastModified)
{
builder["http.last_modified"] = lastModified.ToString("O");
}
builder["fetch.fetched_at"] = fetchedAt.ToString("O");
builder["fetch.content_hash"] = contentHash;
builder["source.client"] = request.ClientName;
return builder.ToImmutable();
}
private string ResolveVendor(string sourceName, ImmutableDictionary<string, string> metadata)
{
if (metadata.TryGetValue("source.vendor", out var vendor) && !string.IsNullOrWhiteSpace(vendor))
{
return vendor.Trim();
}
return ExtractVendorIdentifier(sourceName);
}
private static string? ResolveStream(
ImmutableDictionary<string, string> metadata,
HttpResponseMessage response,
SourceFetchRequest request)
{
foreach (var key in new[] { "source.stream", "connector.stream", "stream" })
{
if (metadata.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
}
if (!string.IsNullOrWhiteSpace(response.Content.Headers.ContentType?.MediaType))
{
return response.Content.Headers.ContentType!.MediaType;
}
return request.RequestUri.Segments.LastOrDefault()?.Trim('/');
}
private static string ResolveFormat(ImmutableDictionary<string, string> metadata, HttpResponseMessage response)
{
if (metadata.TryGetValue("content.format", out var format) && !string.IsNullOrWhiteSpace(format))
{
return format.Trim();
}
return response.Content.Headers.ContentType?.MediaType ?? "unknown";
}
private static string? ResolveUpstreamId(ImmutableDictionary<string, string> metadata, SourceFetchRequest request)
{
var candidateKeys = new[]
{
"aoc.upstream_id",
"upstream.id",
"upstreamId",
"advisory.id",
"advisoryId",
"vulnerability.id",
"vulnerabilityId",
"cve",
"cveId",
"ghsa",
"ghsaId",
"msrc.advisoryId",
"msrc.vulnerabilityId",
"oracle.csaf.entryId",
"ubuntu.advisoryId",
"ics.advisoryId",
"document.id",
};
foreach (var key in candidateKeys)
{
if (metadata.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
}
var segments = request.RequestUri.Segments;
if (segments.Length > 0)
{
var last = segments[^1].Trim('/');
if (!string.IsNullOrEmpty(last))
{
return last;
}
}
return null;
}
private static string? ResolveDocumentVersion(ImmutableDictionary<string, string> metadata, HttpResponseMessage response, DateTimeOffset fetchedAt)
{
var candidateKeys = new[]
{
"upstream.version",
"document.version",
"revision",
"msrc.lastModified",
"msrc.releaseDate",
"ubuntu.version",
"oracle.csaf.revision",
"lastModified",
"modified",
"published",
};
foreach (var key in candidateKeys)
{
if (metadata.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
}
if (response.Content.Headers.LastModified is { } lastModified)
{
return lastModified.ToString("O");
}
if (response.Headers.TryGetValues("Last-Modified", out var values))
{
var first = values.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(first))
{
return first.Trim();
}
}
return fetchedAt.ToString("O");
}
private static RawSignatureMetadata CreateSignatureMetadata(ImmutableDictionary<string, string> metadata)
{
if (!TryGetBoolean(metadata, out var present, "upstream.signature.present", "signature.present"))
{
return new RawSignatureMetadata(false);
}
if (!present)
{
return new RawSignatureMetadata(false);
}
var format = GetMetadataValue(metadata, "upstream.signature.format", "signature.format");
var keyId = GetMetadataValue(metadata, "upstream.signature.key_id", "signature.key_id");
var signature = GetMetadataValue(metadata, "upstream.signature.sig", "signature.sig");
var certificate = GetMetadataValue(metadata, "upstream.signature.certificate", "signature.certificate");
var digest = GetMetadataValue(metadata, "upstream.signature.digest", "signature.digest");
return new RawSignatureMetadata(true, format, keyId, signature, certificate, digest);
}
private static bool TryGetBoolean(ImmutableDictionary<string, string> metadata, out bool value, params string[] keys)
{
foreach (var key in keys)
{
if (metadata.TryGetValue(key, out var raw) && bool.TryParse(raw, out value))
{
return true;
}
}
value = default;
return false;
}
private static string? GetMetadataValue(ImmutableDictionary<string, string> metadata, params string[] keys)
{
foreach (var key in keys)
{
if (metadata.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
}
return null;
}
private static string ExtractVendorIdentifier(string sourceName)
{
if (string.IsNullOrWhiteSpace(sourceName))
{
return "unknown";
}
var normalized = sourceName.Trim();
var separatorIndex = normalized.LastIndexOfAny(new[] { '.', ':' });
if (separatorIndex >= 0 && separatorIndex < normalized.Length - 1)
{
return normalized[(separatorIndex + 1)..];
}
return normalized;
}
private static void ApplyRawDocumentMetadata(IDictionary<string, string> metadata, AdvisoryRawDocument document)
{
metadata["tenant"] = document.Tenant;
metadata["source.vendor"] = document.Source.Vendor;
metadata["source.connector_version"] = document.Source.ConnectorVersion;
if (!string.IsNullOrWhiteSpace(document.Source.Stream))
{
metadata["source.stream"] = document.Source.Stream!;
}
metadata["upstream.upstream_id"] = document.Upstream.UpstreamId;
metadata["upstream.content_hash"] = document.Upstream.ContentHash;
if (!string.IsNullOrWhiteSpace(document.Upstream.DocumentVersion))
{
metadata["upstream.document_version"] = document.Upstream.DocumentVersion!;
}
}
public async Task<SourceFetchContentResult> FetchContentAsync(SourceFetchRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);

View File

@@ -5,6 +5,8 @@ using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Connector.Common.Xml;
using StellaOps.Concelier.Core.Aoc;
using StellaOps.Concelier.Core.Linksets;
namespace StellaOps.Concelier.Connector.Common.Http;
@@ -86,12 +88,6 @@ public static class ServiceCollectionExtensions
}
else if (options.TrustedRootCertificates.Count > 0 && handler.SslOptions.RemoteCertificateValidationCallback is null)
{
var trustedRoots = new X509Certificate2Collection();
foreach (var certificate in options.TrustedRootCertificates)
{
trustedRoots.Add(certificate);
}
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
{
if (errors == SslPolicyErrors.None)
@@ -106,6 +102,7 @@ public static class ServiceCollectionExtensions
X509Certificate2? certToValidate = certificate as X509Certificate2;
X509Certificate2? disposable = null;
var trustedRootCopies = new X509Certificate2Collection();
try
{
if (certToValidate is null)
@@ -114,10 +111,15 @@ public static class ServiceCollectionExtensions
certToValidate = disposable;
}
foreach (var root in options.TrustedRootCertificates)
{
trustedRootCopies.Add(new X509Certificate2(root.RawData));
}
using var customChain = new X509Chain();
customChain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
customChain.ChainPolicy.CustomTrustStore.Clear();
customChain.ChainPolicy.CustomTrustStore.AddRange(trustedRoots);
customChain.ChainPolicy.CustomTrustStore.AddRange(trustedRootCopies);
customChain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
customChain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
@@ -133,6 +135,11 @@ public static class ServiceCollectionExtensions
}
finally
{
foreach (X509Certificate2 root in trustedRootCopies)
{
root.Dispose();
}
disposable?.Dispose();
}
};
@@ -159,6 +166,8 @@ public static class ServiceCollectionExtensions
services.AddSingleton<XmlSchemaValidator>();
services.AddSingleton<IXmlSchemaValidator>(sp => sp.GetRequiredService<XmlSchemaValidator>());
services.AddSingleton<Fetch.IJitterSource, Fetch.CryptoJitterSource>();
services.AddConcelierAocGuards();
services.AddConcelierLinksetMappers();
services.AddSingleton<Fetch.RawDocumentStorage>();
services.AddSingleton<Fetch.SourceFetchService>();

View File

@@ -179,24 +179,21 @@ internal static class SourceHttpClientConfigurationBinder
throw new FileNotFoundException(message, resolvedPath);
}
foreach (var certificate in LoadCertificates(resolvedPath))
{
try
{
AddTrustedCertificate(options, certificate);
logger?.LogInformation(
"Source HTTP client '{ClientName}' loaded trusted root certificate '{Thumbprint}' from '{Path}'.",
clientName,
certificate.Thumbprint,
resolvedPath);
}
finally
{
certificate.Dispose();
}
}
}
}
foreach (var certificate in LoadCertificates(resolvedPath))
{
var thumbprint = certificate.Thumbprint;
var added = AddTrustedCertificate(options, certificate);
if (added)
{
logger?.LogInformation(
"Source HTTP client '{ClientName}' loaded trusted root certificate '{Thumbprint}' from '{Path}'.",
clientName,
thumbprint,
resolvedPath);
}
}
}
}
private static void ApplyProxyConfiguration(
IConfigurationSection section,
@@ -325,22 +322,31 @@ internal static class SourceHttpClientConfigurationBinder
return certificates;
}
private static void AddTrustedCertificate(SourceHttpClientOptions options, X509Certificate2 certificate)
{
if (certificate is null)
{
return;
}
if (options.TrustedRootCertificates.Any(existing =>
string.Equals(existing.Thumbprint, certificate.Thumbprint, StringComparison.OrdinalIgnoreCase)))
{
return;
}
options.TrustedRootCertificates.Add(certificate);
}
private static bool AddTrustedCertificate(SourceHttpClientOptions options, X509Certificate2 certificate)
{
if (certificate is null)
{
return false;
}
var thumbprint = certificate.Thumbprint;
if (string.IsNullOrWhiteSpace(thumbprint))
{
certificate.Dispose();
return false;
}
if (options.TrustedRootCertificates.Any(existing =>
string.Equals(existing.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase)))
{
certificate.Dispose();
return false;
}
options.TrustedRootCertificates.Add(certificate);
return true;
}
// Helper extension method to copy certificate (preserves private key if present)
private static X509Certificate2 CopyWithPrivateKeyIfAvailable(this X509Certificate2 certificate)
{

View File

@@ -16,5 +16,6 @@
<ProjectReference Include="..\StellaOps.Concelier.Storage.Mongo\StellaOps.Concelier.Storage.Mongo.csproj" />
<ProjectReference Include="..\StellaOps.Concelier.Normalization\StellaOps.Concelier.Normalization.csproj" />
<ProjectReference Include="../StellaOps.Plugin/StellaOps.Plugin.csproj" />
<ProjectReference Include="..\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,83 @@
using System.Collections.Immutable;
using System.Text.Json;
using StellaOps.Aoc;
using StellaOps.Concelier.Core.Aoc;
using StellaOps.Concelier.RawModels;
namespace StellaOps.Concelier.Core.Tests.Aoc;
public sealed class AdvisoryRawWriteGuardTests
{
private static AdvisoryRawDocument CreateDocument(
string tenant = "tenant-a",
bool signaturePresent = false,
bool includeSignaturePayload = true)
{
using var rawDocument = JsonDocument.Parse("""{"id":"demo"}""");
var signature = signaturePresent
? new RawSignatureMetadata(
Present: true,
Format: "dsse",
KeyId: "key-1",
Signature: includeSignaturePayload ? "base64signature" : null)
: new RawSignatureMetadata(false);
return new AdvisoryRawDocument(
Tenant: tenant,
Source: new RawSourceMetadata("vendor-x", "connector-y", "1.0.0"),
Upstream: new RawUpstreamMetadata(
UpstreamId: "GHSA-xxxx",
DocumentVersion: "1",
RetrievedAt: DateTimeOffset.UtcNow,
ContentHash: "sha256:abc",
Signature: signature,
Provenance: ImmutableDictionary<string, string>.Empty),
Content: new RawContent(
Format: "OSV",
SpecVersion: "1.0",
Raw: rawDocument.RootElement.Clone()),
Identifiers: new RawIdentifiers(
Aliases: ImmutableArray.Create("GHSA-xxxx"),
PrimaryId: "GHSA-xxxx"),
Linkset: new RawLinkset
{
Aliases = ImmutableArray<string>.Empty,
PackageUrls = ImmutableArray<string>.Empty,
Cpes = ImmutableArray<string>.Empty,
References = ImmutableArray<RawReference>.Empty,
ReconciledFrom = ImmutableArray<string>.Empty,
Notes = ImmutableDictionary<string, string>.Empty
});
}
[Fact]
public void EnsureValid_AllowsMinimalDocument()
{
var guard = new AdvisoryRawWriteGuard(new AocWriteGuard());
var document = CreateDocument();
guard.EnsureValid(document);
}
[Fact]
public void EnsureValid_ThrowsWhenTenantMissing()
{
var guard = new AdvisoryRawWriteGuard(new AocWriteGuard());
var document = CreateDocument(tenant: string.Empty);
var exception = Assert.Throws<ConcelierAocGuardException>(() => guard.EnsureValid(document));
Assert.Equal("ERR_AOC_004", exception.PrimaryErrorCode);
Assert.Contains(exception.Violations, violation => violation.ErrorCode == "ERR_AOC_004" && violation.Path == "/tenant");
}
[Fact]
public void EnsureValid_ThrowsWhenSignaturePayloadMissing()
{
var guard = new AdvisoryRawWriteGuard(new AocWriteGuard());
var document = CreateDocument(signaturePresent: true, includeSignaturePayload: false);
var exception = Assert.Throws<ConcelierAocGuardException>(() => guard.EnsureValid(document));
Assert.Equal("ERR_AOC_005", exception.PrimaryErrorCode);
Assert.Contains(exception.Violations, violation => violation.ErrorCode == "ERR_AOC_005");
}
}

View File

@@ -0,0 +1,125 @@
using System.Collections.Immutable;
using System.Linq;
using System.Text.Json;
using StellaOps.Concelier.Core.Linksets;
using StellaOps.Concelier.RawModels;
using Xunit;
namespace StellaOps.Concelier.Core.Tests.Linksets;
public sealed class AdvisoryLinksetMapperTests
{
[Fact]
public void Map_CollectsSignalsFromIdentifiersAndContent()
{
using var contentDoc = JsonDocument.Parse(
"""
{
"cve": { "id": "CVE-2025-0001" },
"metadata": {
"ghsa": "GHSA-xxxx-yyyy-zzzz"
},
"affected": [
{
"package": { "purl": "pkg:npm/package-a@1.0.0" },
"cpe": "cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*"
}
],
"references": [
{ "type": "Advisory", "url": "https://example.test/advisory" },
{ "url": "https://example.test/patch", "source": "vendor" }
]
}
""");
var document = new AdvisoryRawDocument(
Tenant: "tenant-a",
Source: new RawSourceMetadata("vendor", "connector", "1.0.0"),
Upstream: new RawUpstreamMetadata(
UpstreamId: "GHSA-xxxx-yyyy-zzzz",
DocumentVersion: "1",
RetrievedAt: DateTimeOffset.UtcNow,
ContentHash: "sha256:abc",
Signature: new RawSignatureMetadata(false),
Provenance: ImmutableDictionary<string, string>.Empty),
Content: new RawContent(
Format: "OSV",
SpecVersion: "1.0",
Raw: contentDoc.RootElement.Clone()),
Identifiers: new RawIdentifiers(
Aliases: ImmutableArray.Create("GHSA-xxxx-yyyy-zzzz"),
PrimaryId: "GHSA-xxxx-yyyy-zzzz"),
Linkset: new RawLinkset());
var mapper = new AdvisoryLinksetMapper();
var result = mapper.Map(document);
Assert.Equal(new[] { "cve-2025-0001", "ghsa-xxxx-yyyy-zzzz" }, result.Aliases);
Assert.Equal(new[] { "pkg:npm/package-a@1.0.0" }, result.PackageUrls);
Assert.Equal(new[] { "cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*" }, result.Cpes);
Assert.Equal(2, result.References.Length);
Assert.Contains(result.References, reference => reference.Type == "advisory" && reference.Url == "https://example.test/advisory");
Assert.Contains(result.References, reference => reference.Type == "unspecified" && reference.Url == "https://example.test/patch" && reference.Source == "vendor");
var expectedPointers = new[]
{
"/content/raw/affected/0/cpe",
"/content/raw/affected/0/package/purl",
"/content/raw/cve/id",
"/content/raw/metadata/ghsa",
"/content/raw/references/0/url",
"/content/raw/references/1/url",
"/identifiers/aliases/0",
"/identifiers/primary"
};
Assert.Equal(expectedPointers.OrderBy(static value => value, StringComparer.Ordinal), result.ReconciledFrom);
}
[Fact]
public void Map_DeduplicatesValuesButRetainsMultipleOrigins()
{
using var contentDoc = JsonDocument.Parse(
"""
{
"aliases": ["CVE-2025-0002", "CVE-2025-0002"],
"packages": [
{ "coordinates": "pkg:npm/package-b@2.0.0" },
{ "coordinates": "pkg:npm/package-b@2.0.0" }
]
}
""");
var document = new AdvisoryRawDocument(
Tenant: "tenant-a",
Source: new RawSourceMetadata("vendor", "connector", "1.0.0"),
Upstream: new RawUpstreamMetadata(
UpstreamId: "GHSA-example",
DocumentVersion: "1",
RetrievedAt: DateTimeOffset.UtcNow,
ContentHash: "sha256:def",
Signature: new RawSignatureMetadata(false),
Provenance: ImmutableDictionary<string, string>.Empty),
Content: new RawContent(
Format: "custom",
SpecVersion: null,
Raw: contentDoc.RootElement.Clone()),
Identifiers: new RawIdentifiers(
Aliases: ImmutableArray<string>.Empty,
PrimaryId: "GHSA-example"),
Linkset: new RawLinkset());
var mapper = new AdvisoryLinksetMapper();
var result = mapper.Map(document);
Assert.Equal(new[] { "cve-2025-0002" }, result.Aliases);
Assert.Equal(new[] { "pkg:npm/package-b@2.0.0" }, result.PackageUrls);
Assert.Contains("/content/raw/aliases/0", result.ReconciledFrom);
Assert.Contains("/content/raw/aliases/1", result.ReconciledFrom);
Assert.Contains("/content/raw/packages/0/coordinates", result.ReconciledFrom);
Assert.Contains("/content/raw/packages/1/coordinates", result.ReconciledFrom);
}
}

View File

@@ -0,0 +1,151 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Text.Json;
using StellaOps.Concelier.Core.Linksets;
using StellaOps.Concelier.Models.Observations;
using StellaOps.Concelier.RawModels;
using Xunit;
namespace StellaOps.Concelier.Core.Tests.Linksets;
public sealed class AdvisoryObservationFactoryTests
{
private static readonly DateTimeOffset SampleTimestamp = DateTimeOffset.Parse("2025-10-26T12:34:56Z");
[Fact]
public void Create_NormalizesLinksetIdentifiersAndReferences()
{
var factory = new AdvisoryObservationFactory();
var rawDocument = BuildRawDocument(
identifiers: new RawIdentifiers(
Aliases: ImmutableArray.Create(" CVE-2025-0001 ", "ghsa-XXXX-YYYY"),
PrimaryId: "GHSA-XXXX-YYYY"),
linkset: new RawLinkset
{
PackageUrls = ImmutableArray.Create("pkg:NPM/left-pad@1.0.0", "pkg:npm/left-pad@1.0.0?foo=bar"),
Cpes = ImmutableArray.Create("cpe:/a:Example:Product:1.0", "cpe:/a:example:product:1.0"),
Aliases = ImmutableArray.Create(" CVE-2025-0001 "),
References = ImmutableArray.Create(
new RawReference("Advisory", " https://example.test/advisory "),
new RawReference("ADVISORY", "https://example.test/advisory"))
});
var observation = factory.Create(rawDocument, SampleTimestamp);
Assert.Equal(SampleTimestamp, observation.CreatedAt);
Assert.Equal(new[] { "cve-2025-0001", "ghsa-xxxx-yyyy" }, observation.Linkset.Aliases);
Assert.Equal(new[] { "pkg:npm/left-pad@1.0.0" }, observation.Linkset.Purls);
Assert.Equal(new[] { "cpe:2.3:a:example:product:1.0:*:*:*:*:*:*:*" }, observation.Linkset.Cpes);
var reference = Assert.Single(observation.Linkset.References);
Assert.Equal("advisory", reference.Type);
Assert.Equal("https://example.test/advisory", reference.Url);
}
[Fact]
public void Create_SetsSourceAndUpstreamFields()
{
var factory = new AdvisoryObservationFactory();
var upstreamProvenance = ImmutableDictionary.CreateRange(new Dictionary<string, string>
{
["api"] = "https://api.example.test/v1/feed"
});
var rawDocument = BuildRawDocument(
source: new RawSourceMetadata("vendor-x", "connector-y", "2.3.4", Stream: "stable"),
upstream: new RawUpstreamMetadata(
UpstreamId: "doc-123",
DocumentVersion: "2025.10.26",
RetrievedAt: SampleTimestamp,
ContentHash: "sha256:abcdef",
Signature: new RawSignatureMetadata(true, "dsse", "key-1", "signature-bytes"),
Provenance: upstreamProvenance),
identifiers: new RawIdentifiers(ImmutableArray<string>.Empty, "doc-123"),
linkset: new RawLinkset());
var observation = factory.Create(rawDocument);
Assert.Equal("vendor-x", observation.Source.Vendor);
Assert.Equal("stable", observation.Source.Stream);
Assert.Equal("https://api.example.test/v1/feed", observation.Source.Api);
Assert.Equal("2.3.4", observation.Source.CollectorVersion);
Assert.Equal("doc-123", observation.Upstream.UpstreamId);
Assert.Equal("2025.10.26", observation.Upstream.DocumentVersion);
Assert.Equal("sha256:abcdef", observation.Upstream.ContentHash);
Assert.True(observation.Upstream.Signature.Present);
Assert.Equal("dsse", observation.Upstream.Signature.Format);
Assert.Equal(upstreamProvenance, observation.Upstream.Metadata);
}
[Fact]
public void Create_StoresNotesAsAttributes()
{
var factory = new AdvisoryObservationFactory();
var notes = ImmutableDictionary.CreateRange(new Dictionary<string, string>
{
["range-introduced"] = "1.0.0",
["range-fixed"] = "1.0.5"
});
var rawDocument = BuildRawDocument(
identifiers: new RawIdentifiers(ImmutableArray<string>.Empty, "primary"),
linkset: new RawLinkset
{
Notes = notes,
ReconciledFrom = ImmutableArray.Create("connector-a", "connector-b")
},
supersedes: "tenant-a:vendor-x:previous:sha256:123");
var observation = factory.Create(rawDocument);
Assert.Equal("1.0.0", observation.Attributes["linkset.note.range-introduced"]);
Assert.Equal("1.0.5", observation.Attributes["linkset.note.range-fixed"]);
Assert.Equal("tenant-a:vendor-x:previous:sha256:123", observation.Attributes["supersedes"]);
Assert.Equal("connector-a;connector-b", observation.Attributes["linkset.reconciled_from"]);
}
private static AdvisoryRawDocument BuildRawDocument(
RawSourceMetadata? source = null,
RawUpstreamMetadata? upstream = null,
RawIdentifiers? identifiers = null,
RawLinkset? linkset = null,
string tenant = "tenant-a",
string? supersedes = null)
{
source ??= new RawSourceMetadata(
Vendor: "vendor-x",
Connector: "connector-y",
ConnectorVersion: "1.0.0",
Stream: null);
upstream ??= new RawUpstreamMetadata(
UpstreamId: "doc-1",
DocumentVersion: "v1",
RetrievedAt: SampleTimestamp,
ContentHash: "sha256:123",
Signature: new RawSignatureMetadata(false),
Provenance: ImmutableDictionary<string, string>.Empty);
identifiers ??= new RawIdentifiers(
Aliases: ImmutableArray<string>.Empty,
PrimaryId: "doc-1");
linkset ??= new RawLinkset();
using var document = JsonDocument.Parse("""{"id":"doc-1"}""");
var content = new RawContent(
Format: "csaf",
SpecVersion: "2.0",
Raw: document.RootElement.Clone(),
Encoding: null);
return new AdvisoryRawDocument(
Tenant: tenant,
Source: source,
Upstream: upstream,
Content: content,
Identifiers: identifiers,
Linkset: linkset,
Supersedes: supersedes);
}
}

View File

@@ -65,6 +65,9 @@ public sealed class AdvisoryObservationQueryServiceTests
Assert.Equal("https://example.test/advisory-1", result.Linkset.References[0].Url);
Assert.Equal("https://example.test/advisory-2", result.Linkset.References[1].Url);
Assert.Equal("patch", result.Linkset.References[2].Type);
Assert.False(result.HasMore);
Assert.Null(result.NextCursor);
}
[Fact]
@@ -100,6 +103,9 @@ public sealed class AdvisoryObservationQueryServiceTests
Assert.Equal(2, result.Observations.Length);
Assert.All(result.Observations, observation =>
Assert.Contains(observation.Linkset.Aliases, alias => alias is "cve-2025-0001" or "cve-2025-9999"));
Assert.False(result.HasMore);
Assert.Null(result.NextCursor);
}
[Fact]
@@ -141,6 +147,62 @@ public sealed class AdvisoryObservationQueryServiceTests
Assert.Equal("tenant-a:ghsa:beta:1", result.Observations[0].ObservationId);
Assert.Equal(new[] { "pkg:pypi/package-b@2.0.0" }, result.Linkset.Purls);
Assert.Equal(new[] { "cpe:/a:vendor:product:2.0" }, result.Linkset.Cpes);
Assert.False(result.HasMore);
Assert.Null(result.NextCursor);
}
[Fact]
public async Task QueryAsync_WithLimitEmitsCursorForNextPage()
{
var now = DateTimeOffset.UtcNow;
var observations = new[]
{
CreateObservation(
observationId: "tenant-a:source:1",
tenant: "tenant-a",
aliases: new[] { "CVE-2025-2000" },
purls: Array.Empty<string>(),
cpes: Array.Empty<string>(),
references: Array.Empty<AdvisoryObservationReference>(),
createdAt: now),
CreateObservation(
observationId: "tenant-a:source:2",
tenant: "tenant-a",
aliases: new[] { "CVE-2025-2001" },
purls: Array.Empty<string>(),
cpes: Array.Empty<string>(),
references: Array.Empty<AdvisoryObservationReference>(),
createdAt: now.AddMinutes(-1)),
CreateObservation(
observationId: "tenant-a:source:3",
tenant: "tenant-a",
aliases: new[] { "CVE-2025-2002" },
purls: Array.Empty<string>(),
cpes: Array.Empty<string>(),
references: Array.Empty<AdvisoryObservationReference>(),
createdAt: now.AddMinutes(-2))
};
var lookup = new InMemoryLookup(observations);
var service = new AdvisoryObservationQueryService(lookup);
var firstPage = await service.QueryAsync(
new AdvisoryObservationQueryOptions("tenant-a", limit: 2),
CancellationToken.None);
Assert.Equal(2, firstPage.Observations.Length);
Assert.True(firstPage.HasMore);
Assert.NotNull(firstPage.NextCursor);
var secondPage = await service.QueryAsync(
new AdvisoryObservationQueryOptions("tenant-a", limit: 2, cursor: firstPage.NextCursor),
CancellationToken.None);
Assert.Single(secondPage.Observations);
Assert.False(secondPage.HasMore);
Assert.Null(secondPage.NextCursor);
Assert.Equal("tenant-a:source:3", secondPage.Observations[0].ObservationId);
}
private static AdvisoryObservation CreateObservation(
@@ -206,26 +268,59 @@ public sealed class AdvisoryObservationQueryServiceTests
return ValueTask.FromResult<IReadOnlyList<AdvisoryObservation>>(Array.Empty<AdvisoryObservation>());
}
public ValueTask<IReadOnlyList<AdvisoryObservation>> FindByAliasesAsync(
public ValueTask<IReadOnlyList<AdvisoryObservation>> FindByFiltersAsync(
string tenant,
IReadOnlyCollection<string> observationIds,
IReadOnlyCollection<string> aliases,
IReadOnlyCollection<string> purls,
IReadOnlyCollection<string> cpes,
AdvisoryObservationCursor? cursor,
int limit,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
ArgumentNullException.ThrowIfNull(observationIds);
ArgumentNullException.ThrowIfNull(aliases);
ArgumentNullException.ThrowIfNull(purls);
ArgumentNullException.ThrowIfNull(cpes);
if (limit <= 0)
{
throw new ArgumentOutOfRangeException(nameof(limit));
}
cancellationToken.ThrowIfCancellationRequested();
if (!_observationsByTenant.TryGetValue(tenant, out var observations) || aliases.Count == 0)
if (!_observationsByTenant.TryGetValue(tenant, out var observations))
{
return ValueTask.FromResult<IReadOnlyList<AdvisoryObservation>>(Array.Empty<AdvisoryObservation>());
}
var observationIdSet = observationIds.ToImmutableHashSet(StringComparer.Ordinal);
var aliasSet = aliases.ToImmutableHashSet(StringComparer.Ordinal);
var matches = observations
.Where(observation => observation.Linkset.Aliases.Any(aliasSet.Contains))
var purlSet = purls.ToImmutableHashSet(StringComparer.Ordinal);
var cpeSet = cpes.ToImmutableHashSet(StringComparer.Ordinal);
var filtered = observations
.Where(observation =>
(observationIdSet.Count == 0 || observationIdSet.Contains(observation.ObservationId)) &&
(aliasSet.Count == 0 || observation.Linkset.Aliases.Any(aliasSet.Contains)) &&
(purlSet.Count == 0 || observation.Linkset.Purls.Any(purlSet.Contains)) &&
(cpeSet.Count == 0 || observation.Linkset.Cpes.Any(cpeSet.Contains)));
if (cursor.HasValue)
{
var createdAt = cursor.Value.CreatedAt;
var observationId = cursor.Value.ObservationId;
filtered = filtered.Where(observation =>
observation.CreatedAt < createdAt
|| (observation.CreatedAt == createdAt && string.CompareOrdinal(observation.ObservationId, observationId) > 0));
}
var page = filtered
.OrderByDescending(static observation => observation.CreatedAt)
.ThenBy(static observation => observation.ObservationId, StringComparer.Ordinal)
.Take(limit)
.ToImmutableArray();
return ValueTask.FromResult<IReadOnlyList<AdvisoryObservation>>(matches);
return ValueTask.FromResult<IReadOnlyList<AdvisoryObservation>>(page);
}
}
}

View File

@@ -0,0 +1,143 @@
using System;
using System.Collections.Immutable;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Aoc;
using StellaOps.Concelier.Core.Aoc;
using StellaOps.Concelier.Core.Linksets;
using StellaOps.Concelier.Core.Raw;
using StellaOps.Concelier.RawModels;
using Xunit;
namespace StellaOps.Concelier.Core.Tests.Raw;
public sealed class AdvisoryRawServiceTests
{
[Fact]
public async Task IngestAsync_RemovesClientSupersedesBeforeUpsert()
{
var repository = new RecordingRepository();
var service = CreateService(repository);
var document = CreateDocument() with { Supersedes = " previous-id " };
var storedDocument = document.WithSupersedes("advisory_raw:vendor-x:ghsa-xxxx:sha256-2");
var expectedResult = new AdvisoryRawUpsertResult(true, CreateRecord(storedDocument));
repository.NextResult = expectedResult;
var result = await service.IngestAsync(document, CancellationToken.None);
Assert.NotNull(repository.CapturedDocument);
Assert.Null(repository.CapturedDocument!.Supersedes);
Assert.Equal(expectedResult.Record.Document.Supersedes, result.Record.Document.Supersedes);
}
[Fact]
public async Task IngestAsync_PropagatesRepositoryDuplicateResult()
{
var repository = new RecordingRepository();
var service = CreateService(repository);
var existingDocument = CreateDocument();
var expectedResult = new AdvisoryRawUpsertResult(false, CreateRecord(existingDocument));
repository.NextResult = expectedResult;
var result = await service.IngestAsync(CreateDocument(), CancellationToken.None);
Assert.False(result.Inserted);
Assert.Same(expectedResult.Record, result.Record);
}
private static AdvisoryRawService CreateService(RecordingRepository repository)
{
var writeGuard = new AdvisoryRawWriteGuard(new AocWriteGuard());
var linksetMapper = new PassthroughLinksetMapper();
return new AdvisoryRawService(
repository,
writeGuard,
new AocWriteGuard(),
linksetMapper,
TimeProvider.System,
NullLogger<AdvisoryRawService>.Instance);
}
private static AdvisoryRawDocument CreateDocument()
{
using var raw = JsonDocument.Parse("""{"id":"demo"}""");
return new AdvisoryRawDocument(
Tenant: "Tenant-A",
Source: new RawSourceMetadata("Vendor-X", "connector-y", "1.0.0"),
Upstream: new RawUpstreamMetadata(
UpstreamId: "GHSA-xxxx",
DocumentVersion: "1",
RetrievedAt: DateTimeOffset.UtcNow,
ContentHash: "sha256:abc",
Signature: new RawSignatureMetadata(
Present: true,
Format: "dsse",
KeyId: "key-1",
Signature: "base64signature"),
Provenance: ImmutableDictionary<string, string>.Empty),
Content: new RawContent(
Format: "OSV",
SpecVersion: "1.0",
Raw: raw.RootElement.Clone()),
Identifiers: new RawIdentifiers(
Aliases: ImmutableArray.Create("GHSA-xxxx"),
PrimaryId: "GHSA-xxxx"),
Linkset: new RawLinkset
{
Aliases = ImmutableArray<string>.Empty,
PackageUrls = ImmutableArray<string>.Empty,
Cpes = ImmutableArray<string>.Empty,
References = ImmutableArray<RawReference>.Empty,
ReconciledFrom = ImmutableArray<string>.Empty,
Notes = ImmutableDictionary<string, string>.Empty
});
}
private static AdvisoryRawRecord CreateRecord(AdvisoryRawDocument document)
=> new(
Id: "advisory_raw:vendor-x:ghsa-xxxx:sha256-1",
Document: document,
IngestedAt: DateTimeOffset.UtcNow,
CreatedAt: document.Upstream.RetrievedAt);
private sealed class RecordingRepository : IAdvisoryRawRepository
{
public AdvisoryRawDocument? CapturedDocument { get; private set; }
public AdvisoryRawUpsertResult? NextResult { get; set; }
public Task<AdvisoryRawUpsertResult> UpsertAsync(AdvisoryRawDocument document, CancellationToken cancellationToken)
{
if (NextResult is null)
{
throw new InvalidOperationException("NextResult must be set before calling UpsertAsync.");
}
CapturedDocument = document;
return Task.FromResult(NextResult);
}
public Task<AdvisoryRawRecord?> FindByIdAsync(string tenant, string id, CancellationToken cancellationToken)
=> throw new NotSupportedException();
public Task<AdvisoryRawQueryResult> QueryAsync(AdvisoryRawQueryOptions options, CancellationToken cancellationToken)
=> throw new NotSupportedException();
public Task<IReadOnlyList<AdvisoryRawRecord>> ListForVerificationAsync(
string tenant,
DateTimeOffset since,
DateTimeOffset until,
IReadOnlyCollection<string> sourceVendors,
CancellationToken cancellationToken)
=> throw new NotSupportedException();
}
private sealed class PassthroughLinksetMapper : IAdvisoryLinksetMapper
{
public RawLinkset Map(AdvisoryRawDocument document) => document.Linkset;
}
}

View File

@@ -3,8 +3,10 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj" />
</ItemGroup>
</Project>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj" />
<ProjectReference Include="../StellaOps.Concelier.RawModels/StellaOps.Concelier.RawModels.csproj" />
<ProjectReference Include="../StellaOps.Aoc/StellaOps.Aoc.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,35 @@
using System.Text.Json;
using Microsoft.Extensions.Options;
using StellaOps.Aoc;
using StellaOps.Concelier.RawModels;
namespace StellaOps.Concelier.Core.Aoc;
/// <summary>
/// Aggregation-Only Contract guard applied to raw advisory documents prior to persistence.
/// </summary>
public sealed class AdvisoryRawWriteGuard : IAdvisoryRawWriteGuard
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private readonly IAocGuard _guard;
private readonly AocGuardOptions _options;
public AdvisoryRawWriteGuard(IAocGuard guard, IOptions<AocGuardOptions>? options = null)
{
_guard = guard ?? throw new ArgumentNullException(nameof(guard));
_options = options?.Value ?? AocGuardOptions.Default;
}
public void EnsureValid(AdvisoryRawDocument document)
{
ArgumentNullException.ThrowIfNull(document);
using var payload = JsonDocument.Parse(JsonSerializer.Serialize(document, SerializerOptions));
var result = _guard.Validate(payload.RootElement, _options);
if (!result.IsValid)
{
throw new ConcelierAocGuardException(result);
}
}
}

View File

@@ -0,0 +1,40 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.Aoc;
namespace StellaOps.Concelier.Core.Aoc;
public static class AocServiceCollectionExtensions
{
/// <summary>
/// Registers Aggregation-Only Contract guard services for raw advisory ingestion.
/// </summary>
/// <param name="services">Service collection to configure.</param>
/// <param name="configure">Optional guard configuration.</param>
public static IServiceCollection AddConcelierAocGuards(
this IServiceCollection services,
Action<AocGuardOptions>? configure = null)
{
if (services is null)
{
throw new ArgumentNullException(nameof(services));
}
services.AddAocGuard();
if (configure is not null)
{
services.Configure(configure);
}
services.TryAddSingleton<IAdvisoryRawWriteGuard>(sp =>
{
var guard = sp.GetRequiredService<IAocGuard>();
var options = sp.GetService<IOptions<AocGuardOptions>>();
return new AdvisoryRawWriteGuard(guard, options);
});
return services;
}
}

View File

@@ -0,0 +1,32 @@
using System.Collections.Immutable;
using StellaOps.Aoc;
namespace StellaOps.Concelier.Core.Aoc;
/// <summary>
/// Represents an Aggregation-Only Contract violation produced while validating a raw advisory document.
/// </summary>
public sealed class ConcelierAocGuardException : Exception
{
public ConcelierAocGuardException(AocGuardResult result)
: base("AOC guard validation failed for the provided raw advisory document.")
{
Result = result ?? throw new ArgumentNullException(nameof(result));
}
/// <summary>
/// Guard evaluation result containing the individual violations.
/// </summary>
public AocGuardResult Result { get; }
/// <summary>
/// Collection of violations returned by the guard.
/// </summary>
public ImmutableArray<AocViolation> Violations => Result.Violations;
/// <summary>
/// Primary error code (`ERR_AOC_00x`) associated with the guard failure.
/// </summary>
public string PrimaryErrorCode =>
Violations.IsDefaultOrEmpty ? "ERR_AOC_000" : Violations[0].ErrorCode;
}

View File

@@ -0,0 +1,16 @@
using StellaOps.Concelier.RawModels;
namespace StellaOps.Concelier.Core.Aoc;
/// <summary>
/// Validates raw advisory documents against the Aggregation-Only Contract (AOC)
/// before they are persisted by repositories.
/// </summary>
public interface IAdvisoryRawWriteGuard
{
/// <summary>
/// Ensures the provided raw advisory document satisfies the AOC guard. Throws when violations are detected.
/// </summary>
/// <param name="document">Raw advisory document to validate.</param>
void EnsureValid(AdvisoryRawDocument document);
}

View File

@@ -0,0 +1,308 @@
using System.Collections.Immutable;
using System.Globalization;
using System.Text.Json;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.RawModels;
namespace StellaOps.Concelier.Core.Linksets;
/// <summary>
/// Default implementation of <see cref="IAdvisoryLinksetMapper"/> that walks advisory payloads and emits deterministic linkset hints.
/// </summary>
public sealed partial class AdvisoryLinksetMapper : IAdvisoryLinksetMapper
{
private static readonly HashSet<string> AliasSchemesOfInterest = new(new[]
{
AliasSchemes.Cve,
AliasSchemes.Ghsa,
AliasSchemes.OsV
}, StringComparer.OrdinalIgnoreCase);
public RawLinkset Map(AdvisoryRawDocument document)
{
ArgumentNullException.ThrowIfNull(document);
var aliasSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var purlSet = new HashSet<string>(StringComparer.Ordinal);
var cpeSet = new HashSet<string>(StringComparer.Ordinal);
var referenceKeys = new HashSet<ReferenceKey>(ReferenceKeyComparer.Instance);
var references = new List<RawReference>();
var pointerSet = new HashSet<string>(StringComparer.Ordinal);
SeedAliases(document.Identifiers, aliasSet, pointerSet);
if (document.Content.Raw.ValueKind != JsonValueKind.Undefined &&
document.Content.Raw.ValueKind != JsonValueKind.Null)
{
Traverse(
document.Content.Raw,
"/content/raw",
aliasSet,
purlSet,
cpeSet,
references,
referenceKeys,
pointerSet);
}
var aliases = aliasSet
.Select(static alias => alias.ToLowerInvariant())
.Distinct(StringComparer.Ordinal)
.OrderBy(static alias => alias, StringComparer.Ordinal)
.ToImmutableArray();
var purls = purlSet
.OrderBy(static purl => purl, StringComparer.Ordinal)
.ToImmutableArray();
var cpes = cpeSet
.OrderBy(static cpe => cpe, StringComparer.Ordinal)
.ToImmutableArray();
var referenceArray = references
.OrderBy(static reference => reference.Type, StringComparer.Ordinal)
.ThenBy(static reference => reference.Url, StringComparer.Ordinal)
.ToImmutableArray();
var reconciledFrom = pointerSet
.OrderBy(static pointer => pointer, StringComparer.Ordinal)
.ToImmutableArray();
return new RawLinkset
{
Aliases = aliases,
PackageUrls = purls,
Cpes = cpes,
References = referenceArray,
ReconciledFrom = reconciledFrom,
Notes = ImmutableDictionary<string, string>.Empty
};
}
private static void SeedAliases(
RawIdentifiers identifiers,
HashSet<string> aliasSet,
HashSet<string> pointerSet)
{
if (!identifiers.Aliases.IsDefaultOrEmpty)
{
for (var index = 0; index < identifiers.Aliases.Length; index++)
{
var alias = identifiers.Aliases[index];
if (TryNormalizeAlias(alias, out var normalized))
{
var pointer = AppendPointer("/identifiers/aliases", index.ToString(CultureInfo.InvariantCulture));
pointerSet.Add(pointer);
aliasSet.Add(normalized);
}
}
}
if (TryNormalizeAlias(identifiers.PrimaryId, out var primaryNormalized))
{
pointerSet.Add("/identifiers/primary");
aliasSet.Add(primaryNormalized);
}
}
private static void Traverse(
JsonElement element,
string pointer,
HashSet<string> aliasSet,
HashSet<string> purlSet,
HashSet<string> cpeSet,
List<RawReference> references,
HashSet<ReferenceKey> referenceKeys,
HashSet<string> pointerSet)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
if (TryExtractReference(element, pointer, out var reference, out var referencePointer))
{
pointerSet.Add(referencePointer);
var key = new ReferenceKey(reference.Url, reference.Type, reference.Source);
if (referenceKeys.Add(key))
{
references.Add(reference);
}
}
foreach (var property in element.EnumerateObject())
{
var childPointer = AppendPointer(pointer, property.Name);
Traverse(
property.Value,
childPointer,
aliasSet,
purlSet,
cpeSet,
references,
referenceKeys,
pointerSet);
}
break;
case JsonValueKind.Array:
var index = 0;
foreach (var item in element.EnumerateArray())
{
var childPointer = AppendPointer(pointer, index.ToString(CultureInfo.InvariantCulture));
Traverse(
item,
childPointer,
aliasSet,
purlSet,
cpeSet,
references,
referenceKeys,
pointerSet);
index++;
}
break;
case JsonValueKind.String:
var value = element.GetString();
if (string.IsNullOrWhiteSpace(value))
{
break;
}
var trimmed = value.Trim();
if (TryNormalizeAlias(trimmed, out var aliasNormalized))
{
pointerSet.Add(pointer);
aliasSet.Add(aliasNormalized);
}
if (LinksetNormalization.TryNormalizePackageUrl(trimmed, out var normalizedPurl) &&
!string.IsNullOrEmpty(normalizedPurl))
{
pointerSet.Add(pointer);
purlSet.Add(normalizedPurl);
}
if (LinksetNormalization.TryNormalizeCpe(trimmed, out var normalizedCpe) &&
!string.IsNullOrEmpty(normalizedCpe))
{
pointerSet.Add(pointer);
cpeSet.Add(normalizedCpe);
}
break;
}
}
private static bool TryNormalizeAlias(string? candidate, out string normalized)
{
normalized = string.Empty;
if (!LinksetNormalization.TryNormalizeAlias(candidate, out var canonical))
{
return false;
}
if (!AliasSchemeRegistry.TryGetScheme(canonical, out var scheme))
{
return false;
}
if (!AliasSchemesOfInterest.Contains(scheme))
{
return false;
}
normalized = canonical.ToLowerInvariant();
return true;
}
private static bool TryExtractReference(JsonElement element, string pointer, out RawReference reference, out string referencePointer)
{
reference = default!;
referencePointer = string.Empty;
if (!element.TryGetProperty("url", out var urlElement) || urlElement.ValueKind != JsonValueKind.String)
{
return false;
}
var url = Validation.TrimToNull(urlElement.GetString());
if (url is null || !Validation.LooksLikeHttpUrl(url))
{
return false;
}
string? type = null;
if (element.TryGetProperty("type", out var typeElement) && typeElement.ValueKind == JsonValueKind.String)
{
type = Validation.TrimToNull(typeElement.GetString());
}
else if (element.TryGetProperty("category", out var categoryElement) && categoryElement.ValueKind == JsonValueKind.String)
{
type = Validation.TrimToNull(categoryElement.GetString());
}
var source = element.TryGetProperty("source", out var sourceElement) && sourceElement.ValueKind == JsonValueKind.String
? Validation.TrimToNull(sourceElement.GetString())
: null;
reference = new RawReference(
Type: string.IsNullOrWhiteSpace(type) ? "unspecified" : type!.ToLowerInvariant(),
Url: url,
Source: source);
referencePointer = AppendPointer(pointer, "url");
return true;
}
private static string AppendPointer(string pointer, string token)
{
ArgumentNullException.ThrowIfNull(token);
static string Encode(string value)
=> value.Replace("~", "~0", StringComparison.Ordinal).Replace("/", "~1", StringComparison.Ordinal);
var encoded = Encode(token);
if (string.IsNullOrEmpty(pointer))
{
return "/" + encoded;
}
if (pointer == "/")
{
return "/" + encoded;
}
if (pointer.EndsWith("/", StringComparison.Ordinal))
{
return pointer + encoded;
}
return pointer + "/" + encoded;
}
private readonly record struct ReferenceKey(string Url, string Type, string? Source);
private sealed class ReferenceKeyComparer : IEqualityComparer<ReferenceKey>
{
public static readonly ReferenceKeyComparer Instance = new();
public bool Equals(ReferenceKey x, ReferenceKey y)
{
return string.Equals(x.Url, y.Url, StringComparison.OrdinalIgnoreCase)
&& string.Equals(x.Type, y.Type, StringComparison.OrdinalIgnoreCase)
&& string.Equals(x.Source ?? string.Empty, y.Source ?? string.Empty, StringComparison.OrdinalIgnoreCase);
}
public int GetHashCode(ReferenceKey obj)
{
var hash = StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Url);
hash = (hash * 397) ^ StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Type);
if (!string.IsNullOrEmpty(obj.Source))
{
hash = (hash * 397) ^ StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Source);
}
return hash;
}
}
}

View File

@@ -0,0 +1,288 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Nodes;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Models.Observations;
using StellaOps.Concelier.RawModels;
namespace StellaOps.Concelier.Core.Linksets;
/// <summary>
/// Builds <see cref="AdvisoryObservation"/> instances from raw advisory documents,
/// applying deterministic normalization across identifiers, linkset hints, and metadata.
/// </summary>
internal sealed class AdvisoryObservationFactory : IAdvisoryObservationFactory
{
public AdvisoryObservation Create(AdvisoryRawDocument rawDocument, DateTimeOffset? observedAt = null)
{
ArgumentNullException.ThrowIfNull(rawDocument);
var source = CreateSource(rawDocument.Source, rawDocument.Upstream);
var upstream = CreateUpstream(rawDocument.Upstream);
var content = CreateContent(rawDocument.Content);
var linkset = CreateLinkset(rawDocument.Identifiers, rawDocument.Linkset);
var attributes = CreateAttributes(rawDocument);
var createdAt = (observedAt ?? rawDocument.Upstream.RetrievedAt).ToUniversalTime();
return new AdvisoryObservation(
observationId: BuildObservationId(rawDocument),
tenant: rawDocument.Tenant,
source: source,
upstream: upstream,
content: content,
linkset: linkset,
createdAt: createdAt,
attributes: attributes);
}
private static AdvisoryObservationSource CreateSource(RawSourceMetadata source, RawUpstreamMetadata upstream)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(upstream);
var stream = Validation.TrimToNull(source.Stream) ?? source.Connector;
var api = ResolveApi(source, upstream);
return new AdvisoryObservationSource(
vendor: source.Vendor,
stream: stream,
api: api,
collectorVersion: source.ConnectorVersion);
}
private static string ResolveApi(RawSourceMetadata source, RawUpstreamMetadata upstream)
{
if (upstream.Provenance is not null)
{
if (upstream.Provenance.TryGetValue("api", out var apiValue) && !string.IsNullOrWhiteSpace(apiValue))
{
return apiValue.Trim();
}
if (upstream.Provenance.TryGetValue("endpoint", out var endpoint) && !string.IsNullOrWhiteSpace(endpoint))
{
return endpoint.Trim();
}
}
return source.Connector;
}
private static AdvisoryObservationUpstream CreateUpstream(RawUpstreamMetadata upstream)
{
var signature = new AdvisoryObservationSignature(
upstream.Signature.Present,
upstream.Signature.Format,
upstream.Signature.KeyId,
upstream.Signature.Signature);
var metadata = upstream.Provenance ?? ImmutableDictionary<string, string>.Empty;
return new AdvisoryObservationUpstream(
upstreamId: upstream.UpstreamId,
documentVersion: upstream.DocumentVersion,
fetchedAt: upstream.RetrievedAt.ToUniversalTime(),
receivedAt: upstream.RetrievedAt.ToUniversalTime(),
contentHash: upstream.ContentHash,
signature: signature,
metadata: metadata);
}
private static AdvisoryObservationContent CreateContent(RawContent content)
{
var rawNode = ParseJson(content.Raw);
return new AdvisoryObservationContent(
format: content.Format,
specVersion: content.SpecVersion,
raw: rawNode,
metadata: ImmutableDictionary<string, string>.Empty);
}
private static JsonNode ParseJson(JsonElement element)
{
if (element.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
{
return JsonNode.Parse("{}")!;
}
using var document = JsonDocument.Parse(element.GetRawText());
return JsonNode.Parse(document.RootElement.GetRawText()) ?? JsonNode.Parse("{}")!;
}
private static AdvisoryObservationLinkset CreateLinkset(RawIdentifiers identifiers, RawLinkset linkset)
{
var aliases = NormalizeAliases(identifiers, linkset);
var purls = NormalizePackageUrls(linkset.PackageUrls);
var cpes = NormalizeCpes(linkset.Cpes);
var references = NormalizeReferences(linkset.References);
return new AdvisoryObservationLinkset(aliases, purls, cpes, references);
}
private static IEnumerable<string> NormalizeAliases(RawIdentifiers identifiers, RawLinkset linkset)
{
var aliases = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (LinksetNormalization.TryNormalizeAlias(identifiers.PrimaryId, out var primary))
{
aliases.Add(primary);
}
foreach (var alias in identifiers.Aliases)
{
if (LinksetNormalization.TryNormalizeAlias(alias, out var normalized))
{
aliases.Add(normalized);
}
}
foreach (var alias in linkset.Aliases)
{
if (LinksetNormalization.TryNormalizeAlias(alias, out var normalized))
{
aliases.Add(normalized);
}
}
foreach (var note in linkset.Notes)
{
if (!string.IsNullOrWhiteSpace(note.Value)
&& LinksetNormalization.TryNormalizeAlias(note.Value, out var normalized))
{
aliases.Add(normalized);
}
}
return aliases
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
}
private static IEnumerable<string> NormalizePackageUrls(ImmutableArray<string> packageUrls)
{
if (packageUrls.IsDefaultOrEmpty)
{
return ImmutableArray<string>.Empty;
}
var set = new HashSet<string>(StringComparer.Ordinal);
foreach (var candidate in packageUrls)
{
if (!LinksetNormalization.TryNormalizePackageUrl(candidate, out var normalized) || string.IsNullOrEmpty(normalized))
{
continue;
}
set.Add(normalized);
}
return set
.OrderBy(static value => value, StringComparer.Ordinal)
.ToImmutableArray();
}
private static IEnumerable<string> NormalizeCpes(ImmutableArray<string> cpes)
{
if (cpes.IsDefaultOrEmpty)
{
return ImmutableArray<string>.Empty;
}
var set = new HashSet<string>(StringComparer.Ordinal);
foreach (var cpe in cpes)
{
if (!LinksetNormalization.TryNormalizeCpe(cpe, out var normalized) || string.IsNullOrEmpty(normalized))
{
continue;
}
set.Add(normalized);
}
return set
.OrderBy(static value => value, StringComparer.Ordinal)
.ToImmutableArray();
}
private static IEnumerable<AdvisoryObservationReference> NormalizeReferences(ImmutableArray<RawReference> references)
{
if (references.IsDefaultOrEmpty)
{
return ImmutableArray<AdvisoryObservationReference>.Empty;
}
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var list = new List<AdvisoryObservationReference>();
foreach (var reference in references)
{
var normalized = LinksetNormalization.TryCreateReference(reference.Type, reference.Url);
if (normalized is null)
{
continue;
}
if (!seen.Add(normalized.Url))
{
continue;
}
list.Add(normalized);
}
return list
.OrderBy(static reference => reference.Type, StringComparer.Ordinal)
.ThenBy(static reference => reference.Url, StringComparer.Ordinal)
.ToImmutableArray();
}
private static ImmutableDictionary<string, string> CreateAttributes(AdvisoryRawDocument rawDocument)
{
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
if (!string.IsNullOrWhiteSpace(rawDocument.Supersedes))
{
builder["supersedes"] = rawDocument.Supersedes.Trim();
}
foreach (var note in rawDocument.Linkset.Notes)
{
if (string.IsNullOrWhiteSpace(note.Key) || note.Value is null)
{
continue;
}
var key = $"linkset.note.{note.Key.Trim()}";
builder[key] = note.Value;
}
if (!rawDocument.Linkset.ReconciledFrom.IsDefaultOrEmpty && rawDocument.Linkset.ReconciledFrom.Length > 0)
{
var sources = rawDocument.Linkset.ReconciledFrom
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim())
.ToArray();
if (sources.Length > 0)
{
builder["linkset.reconciled_from"] = string.Join(";", sources);
}
}
return builder.Count == 0 ? ImmutableDictionary<string, string>.Empty : builder.ToImmutable();
}
private static string BuildObservationId(AdvisoryRawDocument rawDocument)
{
// Deterministic observation id format:
// {tenant}:{source.vendor}:{upstreamId}:{contentHash}
var tenant = Validation.EnsureNotNullOrWhiteSpace(rawDocument.Tenant, nameof(rawDocument.Tenant)).ToLowerInvariant();
var vendor = Validation.EnsureNotNullOrWhiteSpace(rawDocument.Source.Vendor, nameof(rawDocument.Source.Vendor)).ToLowerInvariant();
var upstreamId = Validation.TrimToNull(rawDocument.Upstream.UpstreamId) ?? rawDocument.Content.Raw.ToString();
var contentHash = Validation.TrimToNull(rawDocument.Upstream.ContentHash) ?? "sha256:unknown";
return $"{tenant}:{vendor}:{upstreamId}:{contentHash}".ToLowerInvariant();
}
}

View File

@@ -0,0 +1,16 @@
using StellaOps.Concelier.RawModels;
namespace StellaOps.Concelier.Core.Linksets;
/// <summary>
/// Produces canonical linkset hints for advisory raw documents.
/// </summary>
public interface IAdvisoryLinksetMapper
{
/// <summary>
/// Extracts deterministic linkset signals (aliases, package coordinates, references) from the provided raw document.
/// </summary>
/// <param name="document">The advisory raw document to analyse.</param>
/// <returns>A normalized <see cref="RawLinkset"/> payload.</returns>
RawLinkset Map(AdvisoryRawDocument document);
}

View File

@@ -0,0 +1,10 @@
using System;
using StellaOps.Concelier.Models.Observations;
using StellaOps.Concelier.RawModels;
namespace StellaOps.Concelier.Core.Linksets;
internal interface IAdvisoryObservationFactory
{
AdvisoryObservation Create(AdvisoryRawDocument rawDocument, DateTimeOffset? observedAt = null);
}

View File

@@ -0,0 +1,95 @@
using System.Collections.Immutable;
using System.Text;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Models.Observations;
using StellaOps.Concelier.Normalization.Identifiers;
using PackageUrl = StellaOps.Concelier.Normalization.Identifiers.PackageUrl;
namespace StellaOps.Concelier.Core.Linksets;
internal static class LinksetNormalization
{
public static bool TryNormalizeAlias(string? value, out string normalized)
{
if (Validation.TryNormalizeAlias(value, out var alias) && !string.IsNullOrEmpty(alias))
{
normalized = alias;
return true;
}
normalized = string.Empty;
return false;
}
public static bool TryNormalizePackageUrl(string? value, out string normalized)
{
normalized = string.Empty;
if (IdentifierNormalizer.TryNormalizePackageUrl(value, out _, out var packageUrl) && packageUrl is PackageUrl parsed)
{
normalized = CanonicalizePackageUrl(parsed);
return true;
}
var trimmed = Validation.TrimToNull(value);
if (trimmed is null || !trimmed.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase))
{
return false;
}
normalized = trimmed;
return true;
}
public static bool TryNormalizeCpe(string? value, out string normalized)
{
normalized = string.Empty;
if (IdentifierNormalizer.TryNormalizeCpe(value, out var canonical) && !string.IsNullOrEmpty(canonical))
{
normalized = canonical;
return true;
}
var trimmed = Validation.TrimToNull(value);
if (trimmed is null || !trimmed.StartsWith("cpe", StringComparison.OrdinalIgnoreCase))
{
return false;
}
normalized = trimmed.ToLowerInvariant();
return true;
}
public static AdvisoryObservationReference? TryCreateReference(string? type, string? url)
{
var trimmedUrl = Validation.TrimToNull(url);
if (trimmedUrl is null || !Validation.LooksLikeHttpUrl(trimmedUrl))
{
return null;
}
var normalizedType = Validation.TrimToNull(type) ?? "other";
return new AdvisoryObservationReference(normalizedType, trimmedUrl);
}
private static string CanonicalizePackageUrl(PackageUrl packageUrl)
{
var builder = new StringBuilder("pkg:");
builder.Append(packageUrl.Type);
builder.Append('/');
if (!packageUrl.NamespaceSegments.IsDefaultOrEmpty && packageUrl.NamespaceSegments.Length > 0)
{
builder.Append(string.Join('/', packageUrl.NamespaceSegments));
builder.Append('/');
}
builder.Append(packageUrl.Name);
if (!string.IsNullOrEmpty(packageUrl.Version))
{
builder.Append('@');
builder.Append(packageUrl.Version);
}
return builder.ToString();
}
}

View File

@@ -0,0 +1,19 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace StellaOps.Concelier.Core.Linksets;
public static class LinksetServiceCollectionExtensions
{
/// <summary>
/// Registers advisory linkset mappers used by ingestion pipelines.
/// </summary>
public static IServiceCollection AddConcelierLinksetMappers(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.TryAddSingleton<IAdvisoryLinksetMapper, AdvisoryLinksetMapper>();
services.TryAddSingleton<IAdvisoryObservationFactory, AdvisoryObservationFactory>();
return services;
}
}

View File

@@ -0,0 +1,8 @@
namespace StellaOps.Concelier.Core.Observations;
/// <summary>
/// Represents a stable pagination cursor for advisory observations.
/// </summary>
public readonly record struct AdvisoryObservationCursor(
DateTimeOffset CreatedAt,
string ObservationId);

View File

@@ -14,13 +14,17 @@ public sealed record AdvisoryObservationQueryOptions
IReadOnlyCollection<string>? observationIds = null,
IReadOnlyCollection<string>? aliases = null,
IReadOnlyCollection<string>? purls = null,
IReadOnlyCollection<string>? cpes = null)
IReadOnlyCollection<string>? cpes = null,
int? limit = null,
string? cursor = null)
{
Tenant = Validation.EnsureNotNullOrWhiteSpace(tenant, nameof(tenant));
ObservationIds = observationIds ?? Array.Empty<string>();
Aliases = aliases ?? Array.Empty<string>();
Purls = purls ?? Array.Empty<string>();
Cpes = cpes ?? Array.Empty<string>();
Limit = limit;
Cursor = Validation.TrimToNull(cursor);
}
/// <summary>
@@ -47,6 +51,16 @@ public sealed record AdvisoryObservationQueryOptions
/// Optional set of CPE values to filter by.
/// </summary>
public IReadOnlyCollection<string> Cpes { get; }
/// <summary>
/// Optional limit for page size. When null or non-positive the service default is used.
/// </summary>
public int? Limit { get; }
/// <summary>
/// Opaque cursor returned by previous query page.
/// </summary>
public string? Cursor { get; }
}
/// <summary>
@@ -54,7 +68,9 @@ public sealed record AdvisoryObservationQueryOptions
/// </summary>
public sealed record AdvisoryObservationQueryResult(
ImmutableArray<AdvisoryObservation> Observations,
AdvisoryObservationLinksetAggregate Linkset);
AdvisoryObservationLinksetAggregate Linkset,
string? NextCursor,
bool HasMore);
/// <summary>
/// Aggregated linkset built from the observations returned by a query.

View File

@@ -1,4 +1,6 @@
using System.Collections.Immutable;
using System.Globalization;
using System.Text;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Models.Observations;
@@ -9,6 +11,8 @@ namespace StellaOps.Concelier.Core.Observations;
/// </summary>
public sealed class AdvisoryObservationQueryService : IAdvisoryObservationQueryService
{
private const int DefaultPageSize = 200;
private const int MaxPageSize = 500;
private readonly IAdvisoryObservationLookup _lookup;
public AdvisoryObservationQueryService(IAdvisoryObservationLookup lookup)
@@ -29,28 +33,35 @@ public sealed class AdvisoryObservationQueryService : IAdvisoryObservationQueryS
var normalizedPurls = NormalizeSet(options.Purls, static value => value, StringComparer.Ordinal);
var normalizedCpes = NormalizeSet(options.Cpes, static value => value, StringComparer.Ordinal);
IReadOnlyList<AdvisoryObservation> observations;
if (normalizedAliases.Count > 0)
{
observations = await _lookup
.FindByAliasesAsync(normalizedTenant, normalizedAliases, cancellationToken)
.ConfigureAwait(false);
}
else
{
observations = await _lookup
.ListByTenantAsync(normalizedTenant, cancellationToken)
.ConfigureAwait(false);
}
var limit = NormalizeLimit(options.Limit);
var fetchSize = checked(limit + 1);
var matched = observations
var cursor = DecodeCursor(options.Cursor);
var observations = await _lookup
.FindByFiltersAsync(
normalizedTenant,
normalizedObservationIds,
normalizedAliases,
normalizedPurls,
normalizedCpes,
cursor,
fetchSize,
cancellationToken)
.ConfigureAwait(false);
var ordered = observations
.Where(observation => Matches(observation, normalizedObservationIds, normalizedAliases, normalizedPurls, normalizedCpes))
.OrderByDescending(static observation => observation.CreatedAt)
.ThenBy(static observation => observation.ObservationId, StringComparer.Ordinal)
.ToImmutableArray();
var linkset = BuildAggregateLinkset(matched);
return new AdvisoryObservationQueryResult(matched, linkset);
var hasMore = ordered.Length > limit;
var page = hasMore ? ordered.Take(limit).ToImmutableArray() : ordered;
var nextCursor = hasMore ? EncodeCursor(page[^1]) : null;
var linkset = BuildAggregateLinkset(page);
return new AdvisoryObservationQueryResult(page, linkset, nextCursor, hasMore);
}
private static bool Matches(
@@ -113,6 +124,75 @@ public sealed class AdvisoryObservationQueryService : IAdvisoryObservationQueryS
return builder.ToImmutable();
}
private static int NormalizeLimit(int? requestedLimit)
{
if (!requestedLimit.HasValue || requestedLimit.Value <= 0)
{
return DefaultPageSize;
}
var limit = requestedLimit.Value;
if (limit > MaxPageSize)
{
return MaxPageSize;
}
return limit;
}
private static AdvisoryObservationCursor? DecodeCursor(string? cursor)
{
if (string.IsNullOrWhiteSpace(cursor))
{
return null;
}
try
{
var decoded = Convert.FromBase64String(cursor.Trim());
var payload = Encoding.UTF8.GetString(decoded);
var separator = payload.IndexOf(':');
if (separator <= 0 || separator >= payload.Length - 1)
{
throw new FormatException("Cursor is malformed.");
}
var ticksText = payload.AsSpan(0, separator);
if (!long.TryParse(ticksText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var ticks))
{
throw new FormatException("Cursor timestamp is invalid.");
}
var createdAt = new DateTimeOffset(DateTime.SpecifyKind(new DateTime(ticks), DateTimeKind.Utc));
var observationId = payload[(separator + 1)..];
if (string.IsNullOrWhiteSpace(observationId))
{
throw new FormatException("Cursor observation id is missing.");
}
return new AdvisoryObservationCursor(createdAt, observationId);
}
catch (FormatException)
{
throw;
}
catch (Exception ex)
{
throw new FormatException("Cursor is malformed.", ex);
}
}
private static string? EncodeCursor(AdvisoryObservation observation)
{
if (observation is null)
{
return null;
}
var payload = $"{observation.CreatedAt.UtcTicks.ToString(CultureInfo.InvariantCulture)}:{observation.ObservationId}";
return Convert.ToBase64String(Encoding.UTF8.GetBytes(payload));
}
private static AdvisoryObservationLinksetAggregate BuildAggregateLinkset(ImmutableArray<AdvisoryObservation> observations)
{
if (observations.IsDefaultOrEmpty)

View File

@@ -17,13 +17,23 @@ public interface IAdvisoryObservationLookup
CancellationToken cancellationToken);
/// <summary>
/// Finds advisory observations for a tenant that match at least one of the supplied aliases.
/// Finds advisory observations for a tenant that match the supplied filter criteria.
/// </summary>
/// <param name="tenant">Tenant identifier (case-insensitive).</param>
/// <param name="observationIds">Normalized observation identifiers to match against.</param>
/// <param name="aliases">Normalized alias values to match against.</param>
/// <param name="purls">Normalized Package URL values to match against.</param>
/// <param name="cpes">Normalized CPE values to match against.</param>
/// <param name="cursor">Optional cursor describing the last element retrieved in the previous page.</param>
/// <param name="limit">Maximum number of documents to return. Must be positive.</param>
/// <param name="cancellationToken">A cancellation token.</param>
ValueTask<IReadOnlyList<AdvisoryObservation>> FindByAliasesAsync(
ValueTask<IReadOnlyList<AdvisoryObservation>> FindByFiltersAsync(
string tenant,
IReadOnlyCollection<string> observationIds,
IReadOnlyCollection<string> aliases,
IReadOnlyCollection<string> purls,
IReadOnlyCollection<string> cpes,
AdvisoryObservationCursor? cursor,
int limit,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Concelier.Core.Tests")]

View File

@@ -0,0 +1,83 @@
using System.Collections.Immutable;
namespace StellaOps.Concelier.Core.Raw;
/// <summary>
/// Options controlling advisory raw document queries.
/// </summary>
public sealed record AdvisoryRawQueryOptions
{
private int _limit = 50;
public AdvisoryRawQueryOptions(string tenant)
{
Tenant = NormalizeTenant(tenant);
}
/// <summary>
/// Tenant identifier (normalized).
/// </summary>
public string Tenant { get; }
/// <summary>
/// Optional set of source vendors to filter by.
/// </summary>
public ImmutableArray<string> Vendors { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Optional set of upstream identifiers to filter by.
/// </summary>
public ImmutableArray<string> UpstreamIds { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Optional set of alias identifiers (CVE/GHSA/etc.) to filter by.
/// </summary>
public ImmutableArray<string> Aliases { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Optional set of Package URLs to filter by.
/// </summary>
public ImmutableArray<string> PackageUrls { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Optional set of content hashes to filter by.
/// </summary>
public ImmutableArray<string> ContentHashes { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Optional lower bound on ingest time.
/// </summary>
public DateTimeOffset? Since { get; init; }
/// <summary>
/// Maximum number of records to return (defaults to 50, capped at 200).
/// </summary>
public int Limit
{
get => _limit;
init => _limit = Math.Clamp(value, 1, 200);
}
/// <summary>
/// Pagination cursor provided by previous result.
/// </summary>
public string? Cursor { get; init; }
private static string NormalizeTenant(string tenant)
{
if (string.IsNullOrWhiteSpace(tenant))
{
throw new ArgumentException("Tenant must be provided.", nameof(tenant));
}
return tenant.Trim().ToLowerInvariant();
}
}
/// <summary>
/// Query response containing raw advisory records plus paging metadata.
/// </summary>
public sealed record AdvisoryRawQueryResult(
IReadOnlyList<AdvisoryRawRecord> Records,
string? NextCursor,
bool HasMore);

View File

@@ -0,0 +1,19 @@
using StellaOps.Concelier.RawModels;
namespace StellaOps.Concelier.Core.Raw;
/// <summary>
/// Represents a stored advisory raw document together with ingestion metadata.
/// </summary>
public sealed record AdvisoryRawRecord(
string Id,
AdvisoryRawDocument Document,
DateTimeOffset IngestedAt,
DateTimeOffset CreatedAt);
/// <summary>
/// Result produced when attempting to append a raw advisory document.
/// </summary>
public sealed record AdvisoryRawUpsertResult(
bool Inserted,
AdvisoryRawRecord Record);

View File

@@ -0,0 +1,439 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Linq;
using Microsoft.Extensions.Logging;
using StellaOps.Aoc;
using StellaOps.Concelier.Core.Aoc;
using StellaOps.Concelier.Core.Linksets;
using StellaOps.Concelier.RawModels;
namespace StellaOps.Concelier.Core.Raw;
internal sealed class AdvisoryRawService : IAdvisoryRawService
{
private static readonly ImmutableArray<string> EmptyArray = ImmutableArray<string>.Empty;
private readonly IAdvisoryRawRepository _repository;
private readonly IAdvisoryRawWriteGuard _writeGuard;
private readonly IAocGuard _aocGuard;
private readonly IAdvisoryLinksetMapper _linksetMapper;
private readonly TimeProvider _timeProvider;
private readonly ILogger<AdvisoryRawService> _logger;
public AdvisoryRawService(
IAdvisoryRawRepository repository,
IAdvisoryRawWriteGuard writeGuard,
IAocGuard aocGuard,
IAdvisoryLinksetMapper linksetMapper,
TimeProvider timeProvider,
ILogger<AdvisoryRawService> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_writeGuard = writeGuard ?? throw new ArgumentNullException(nameof(writeGuard));
_aocGuard = aocGuard ?? throw new ArgumentNullException(nameof(aocGuard));
_linksetMapper = linksetMapper ?? throw new ArgumentNullException(nameof(linksetMapper));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<AdvisoryRawUpsertResult> IngestAsync(AdvisoryRawDocument document, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(document);
var clientSupersedes = string.IsNullOrWhiteSpace(document.Supersedes)
? null
: document.Supersedes.Trim();
var normalized = Normalize(document);
var enriched = normalized with { Linkset = _linksetMapper.Map(normalized) };
if (!string.IsNullOrEmpty(clientSupersedes))
{
_logger.LogWarning(
"Ignoring client-supplied supersedes pointer for advisory_raw tenant={Tenant} source={Vendor} upstream={UpstreamId} pointer={Supersedes}",
enriched.Tenant,
enriched.Source.Vendor,
enriched.Upstream.UpstreamId,
clientSupersedes);
}
_writeGuard.EnsureValid(enriched);
var result = await _repository.UpsertAsync(enriched, cancellationToken).ConfigureAwait(false);
if (result.Inserted)
{
_logger.LogInformation(
"Ingested advisory_raw document id={DocumentId} tenant={Tenant} source={Vendor} upstream={UpstreamId} hash={Hash} supersedes={Supersedes}",
result.Record.Id,
result.Record.Document.Tenant,
result.Record.Document.Source.Vendor,
result.Record.Document.Upstream.UpstreamId,
result.Record.Document.Upstream.ContentHash,
string.IsNullOrWhiteSpace(result.Record.Document.Supersedes)
? "(none)"
: result.Record.Document.Supersedes);
}
else
{
_logger.LogDebug(
"Skipped advisory_raw duplicate tenant={Tenant} source={Vendor} upstream={UpstreamId} hash={Hash}",
result.Record.Document.Tenant,
result.Record.Document.Source.Vendor,
result.Record.Document.Upstream.UpstreamId,
result.Record.Document.Upstream.ContentHash);
}
return result;
}
public Task<AdvisoryRawRecord?> FindByIdAsync(string tenant, string id, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
ArgumentException.ThrowIfNullOrWhiteSpace(id);
var normalizedTenant = tenant.Trim().ToLowerInvariant();
var normalizedId = id.Trim();
return _repository.FindByIdAsync(normalizedTenant, normalizedId, cancellationToken);
}
public Task<AdvisoryRawQueryResult> QueryAsync(AdvisoryRawQueryOptions options, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
return _repository.QueryAsync(options, cancellationToken);
}
public async Task<AdvisoryRawVerificationResult> VerifyAsync(AdvisoryRawVerificationRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var tenant = NormalizeTenant(request.Tenant);
var windowStart = request.Since.ToUniversalTime();
var windowEnd = request.Until.ToUniversalTime();
if (windowEnd < windowStart)
{
throw new ArgumentException("Verification window end must be greater than or equal to the start.", nameof(request));
}
var inclusionLimit = request.Limit <= 0 ? 0 : request.Limit;
var sourceFilter = request.SourceVendors ?? Array.Empty<string>();
var normalizedSources = sourceFilter.Count == 0
? EmptyArray
: sourceFilter.Select(NormalizeSourceVendor).Distinct(StringComparer.Ordinal).ToImmutableArray();
var codeFilter = request.Codes ?? Array.Empty<string>();
var normalizedCodes = codeFilter.Count == 0
? EmptyArray
: codeFilter
.Where(static code => !string.IsNullOrWhiteSpace(code))
.Select(static code => code.Trim().ToUpperInvariant())
.Distinct(StringComparer.Ordinal)
.ToImmutableArray();
var records = await _repository
.ListForVerificationAsync(tenant, windowStart, windowEnd, normalizedSources, cancellationToken)
.ConfigureAwait(false);
var now = _timeProvider.GetUtcNow();
var violations = new Dictionary<string, VerificationAggregation>(StringComparer.Ordinal);
var checkedCount = 0;
var totalExamples = 0;
foreach (var record in records)
{
cancellationToken.ThrowIfCancellationRequested();
checkedCount++;
AocGuardResult guardResult;
try
{
guardResult = _aocGuard.Validate(ToJsonElement(record.Document));
}
catch (Exception ex)
{
_logger.LogError(
ex,
"AOC guard threw unexpected exception while verifying advisory_raw document id={DocumentId}",
record.Id);
continue;
}
if (guardResult.IsValid || guardResult.Violations.IsDefaultOrEmpty)
{
continue;
}
foreach (var violation in guardResult.Violations)
{
if (!normalizedCodes.IsDefaultOrEmpty &&
!normalizedCodes.Contains(violation.ErrorCode.ToUpperInvariant()))
{
continue;
}
var key = violation.ErrorCode;
if (!violations.TryGetValue(key, out var aggregation))
{
aggregation = new VerificationAggregation(key);
violations.Add(key, aggregation);
}
aggregation.Count++;
if (inclusionLimit <= 0 || totalExamples >= inclusionLimit)
{
aggregation.Truncated = true;
continue;
}
if (aggregation.TryAddExample(CreateExample(record, violation)))
{
totalExamples++;
}
}
}
var orderedViolations = violations.Values
.OrderByDescending(static v => v.Count)
.ThenBy(static v => v.Code, StringComparer.Ordinal)
.Select(static v => new AdvisoryRawVerificationViolation(
v.Code,
v.Count,
v.Examples.ToArray()))
.ToArray();
var truncated = orderedViolations.Any(static v => v.Examples.Count > 0) && totalExamples >= inclusionLimit && inclusionLimit > 0;
return new AdvisoryRawVerificationResult(
tenant,
windowStart,
windowEnd > windowStart ? windowEnd : now,
checkedCount,
orderedViolations,
truncated || violations.Values.Any(static v => v.Truncated));
}
private static AdvisoryRawViolationExample CreateExample(AdvisoryRawRecord record, AocViolation violation)
{
return new AdvisoryRawViolationExample(
record.Document.Source.Vendor,
record.Id,
record.Document.Upstream.ContentHash,
violation.Path);
}
private static string NormalizeTenant(string tenant)
{
if (string.IsNullOrWhiteSpace(tenant))
{
throw new ArgumentException("Tenant must be provided.", nameof(tenant));
}
return tenant.Trim().ToLowerInvariant();
}
private static string NormalizeSourceVendor(string vendor)
{
if (string.IsNullOrWhiteSpace(vendor))
{
return string.Empty;
}
return vendor.Trim().ToLowerInvariant();
}
private AdvisoryRawDocument Normalize(AdvisoryRawDocument document)
{
var tenant = NormalizeTenant(document.Tenant);
var source = NormalizeSource(document.Source);
var upstream = NormalizeUpstream(document.Upstream);
var content = NormalizeContent(document.Content);
var identifiers = NormalizeIdentifiers(document.Identifiers);
var linkset = NormalizeLinkset(document.Linkset);
return new AdvisoryRawDocument(
tenant,
source,
upstream,
content,
identifiers,
linkset,
Supersedes: null);
}
private static RawSourceMetadata NormalizeSource(RawSourceMetadata source)
{
if (source is null)
{
throw new ArgumentNullException(nameof(source));
}
return new RawSourceMetadata(
NormalizeSourceVendor(source.Vendor),
source.Connector?.Trim() ?? string.Empty,
source.ConnectorVersion?.Trim() ?? "unknown",
string.IsNullOrWhiteSpace(source.Stream) ? null : source.Stream.Trim());
}
private static RawUpstreamMetadata NormalizeUpstream(RawUpstreamMetadata upstream)
{
if (upstream is null)
{
throw new ArgumentNullException(nameof(upstream));
}
var provenanceBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
if (upstream.Provenance is not null)
{
foreach (var entry in upstream.Provenance)
{
if (string.IsNullOrWhiteSpace(entry.Key))
{
continue;
}
var key = entry.Key.Trim();
var value = entry.Value?.Trim() ?? string.Empty;
provenanceBuilder[key] = value;
}
}
var signature = NormalizeSignature(upstream.Signature);
return new RawUpstreamMetadata(
upstream.UpstreamId?.Trim() ?? string.Empty,
string.IsNullOrWhiteSpace(upstream.DocumentVersion) ? null : upstream.DocumentVersion.Trim(),
upstream.RetrievedAt.ToUniversalTime(),
upstream.ContentHash?.Trim() ?? string.Empty,
signature,
provenanceBuilder.ToImmutable());
}
private static RawSignatureMetadata NormalizeSignature(RawSignatureMetadata signature)
{
return new RawSignatureMetadata(
signature.Present,
string.IsNullOrWhiteSpace(signature.Format) ? null : signature.Format.Trim(),
string.IsNullOrWhiteSpace(signature.KeyId) ? null : signature.KeyId.Trim(),
string.IsNullOrWhiteSpace(signature.Signature) ? null : signature.Signature.Trim(),
string.IsNullOrWhiteSpace(signature.Certificate) ? null : signature.Certificate.Trim(),
string.IsNullOrWhiteSpace(signature.Digest) ? null : signature.Digest.Trim());
}
private static RawContent NormalizeContent(RawContent content)
{
if (content is null)
{
throw new ArgumentNullException(nameof(content));
}
var clonedRaw = content.Raw.Clone();
return new RawContent(
content.Format?.Trim() ?? string.Empty,
string.IsNullOrWhiteSpace(content.SpecVersion) ? null : content.SpecVersion.Trim(),
clonedRaw,
string.IsNullOrWhiteSpace(content.Encoding) ? null : content.Encoding.Trim());
}
private static RawIdentifiers NormalizeIdentifiers(RawIdentifiers identifiers)
{
var normalizedAliases = identifiers.Aliases
.Where(static alias => !string.IsNullOrWhiteSpace(alias))
.Select(static alias => alias.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
return new RawIdentifiers(
normalizedAliases,
identifiers.PrimaryId?.Trim() ?? string.Empty);
}
private static RawLinkset NormalizeLinkset(RawLinkset linkset)
{
return new RawLinkset
{
Aliases = NormalizeStringArray(linkset.Aliases, StringComparer.OrdinalIgnoreCase),
PackageUrls = NormalizeStringArray(linkset.PackageUrls, StringComparer.Ordinal),
Cpes = NormalizeStringArray(linkset.Cpes, StringComparer.Ordinal),
References = NormalizeReferences(linkset.References),
ReconciledFrom = NormalizeStringArray(linkset.ReconciledFrom, StringComparer.Ordinal),
Notes = linkset.Notes ?? ImmutableDictionary<string, string>.Empty,
};
}
private static ImmutableArray<string> NormalizeStringArray(ImmutableArray<string> values, StringComparer comparer)
{
if (values.IsDefaultOrEmpty)
{
return EmptyArray;
}
return values
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim())
.Distinct(comparer)
.OrderBy(static value => value, comparer)
.ToImmutableArray();
}
private static ImmutableArray<RawReference> NormalizeReferences(ImmutableArray<RawReference> references)
{
if (references.IsDefaultOrEmpty)
{
return ImmutableArray<RawReference>.Empty;
}
var builder = ImmutableArray.CreateBuilder<RawReference>();
foreach (var reference in references)
{
if (string.IsNullOrWhiteSpace(reference.Type) || string.IsNullOrWhiteSpace(reference.Url))
{
continue;
}
builder.Add(new RawReference(
reference.Type.Trim(),
reference.Url.Trim(),
string.IsNullOrWhiteSpace(reference.Source) ? null : reference.Source.Trim()));
}
return builder.ToImmutable();
}
private JsonElement ToJsonElement(AdvisoryRawDocument document)
{
var json = System.Text.Json.JsonSerializer.Serialize(document);
using var jsonDocument = System.Text.Json.JsonDocument.Parse(json);
return jsonDocument.RootElement.Clone();
}
private sealed class VerificationAggregation
{
private readonly List<AdvisoryRawViolationExample> _examples = new();
public VerificationAggregation(string code)
{
Code = code;
}
public string Code { get; }
public int Count { get; set; }
public bool Truncated { get; set; }
public IReadOnlyList<AdvisoryRawViolationExample> Examples => _examples;
public bool TryAddExample(AdvisoryRawViolationExample example)
{
if (Truncated)
{
return false;
}
_examples.Add(example);
return true;
}
}
}

View File

@@ -0,0 +1,37 @@
using StellaOps.Concelier.RawModels;
namespace StellaOps.Concelier.Core.Raw;
/// <summary>
/// Persistence abstraction for raw advisory documents.
/// </summary>
public interface IAdvisoryRawRepository
{
/// <summary>
/// Appends a new raw document or returns the existing record when the content hash already exists.
/// </summary>
/// <param name="document">Document to append.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result describing whether a new document was inserted.</returns>
Task<AdvisoryRawUpsertResult> UpsertAsync(AdvisoryRawDocument document, CancellationToken cancellationToken);
/// <summary>
/// Finds a raw document by identifier within the specified tenant.
/// </summary>
Task<AdvisoryRawRecord?> FindByIdAsync(string tenant, string id, CancellationToken cancellationToken);
/// <summary>
/// Queries raw documents using the supplied filter/paging options.
/// </summary>
Task<AdvisoryRawQueryResult> QueryAsync(AdvisoryRawQueryOptions options, CancellationToken cancellationToken);
/// <summary>
/// Enumerates raw advisory documents for verification runs.
/// </summary>
Task<IReadOnlyList<AdvisoryRawRecord>> ListForVerificationAsync(
string tenant,
DateTimeOffset since,
DateTimeOffset until,
IReadOnlyCollection<string> sourceVendors,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,56 @@
using StellaOps.Concelier.RawModels;
namespace StellaOps.Concelier.Core.Raw;
/// <summary>
/// High-level orchestration for advisory raw ingestion, querying, and verification.
/// </summary>
public interface IAdvisoryRawService
{
Task<AdvisoryRawUpsertResult> IngestAsync(AdvisoryRawDocument document, CancellationToken cancellationToken);
Task<AdvisoryRawRecord?> FindByIdAsync(string tenant, string id, CancellationToken cancellationToken);
Task<AdvisoryRawQueryResult> QueryAsync(AdvisoryRawQueryOptions options, CancellationToken cancellationToken);
Task<AdvisoryRawVerificationResult> VerifyAsync(AdvisoryRawVerificationRequest request, CancellationToken cancellationToken);
}
/// <summary>
/// Verification request parameters.
/// </summary>
public sealed record AdvisoryRawVerificationRequest(
string Tenant,
DateTimeOffset Since,
DateTimeOffset Until,
int Limit,
IReadOnlyCollection<string> SourceVendors,
IReadOnlyCollection<string> Codes);
/// <summary>
/// Verification response summarising guard violations.
/// </summary>
public sealed record AdvisoryRawVerificationResult(
string Tenant,
DateTimeOffset WindowStart,
DateTimeOffset WindowEnd,
int CheckedCount,
IReadOnlyList<AdvisoryRawVerificationViolation> Violations,
bool Truncated);
/// <summary>
/// Aggregated violation entry.
/// </summary>
public sealed record AdvisoryRawVerificationViolation(
string Code,
int Count,
IReadOnlyList<AdvisoryRawViolationExample> Examples);
/// <summary>
/// Sample violation pointer for troubleshooting.
/// </summary>
public sealed record AdvisoryRawViolationExample(
string SourceVendor,
string DocumentId,
string ContentHash,
string Path);

View File

@@ -0,0 +1,16 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace StellaOps.Concelier.Core.Raw;
public static class RawServiceCollectionExtensions
{
public static IServiceCollection AddAdvisoryRawServices(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.TryAddSingleton<IAdvisoryRawService, AdvisoryRawService>();
return services;
}
}

View File

@@ -15,6 +15,9 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Concelier.Models\StellaOps.Concelier.Models.csproj" />
<ProjectReference Include="..\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj" />
<ProjectReference Include="..\StellaOps.Concelier.Normalization\StellaOps.Concelier.Normalization.csproj" />
<ProjectReference Include="..\StellaOps.Plugin\StellaOps.Plugin.csproj" />
<ProjectReference Include="..\StellaOps.Aoc\StellaOps.Aoc.csproj" />
</ItemGroup>
</Project>

View File

@@ -2,14 +2,17 @@
> **AOC Reminder:** ingestion aggregates and links only—no precedence, normalization, or severity computation. Derived data lives in Policy/overlay services.
| ID | Status | Owner(s) | Depends on | Notes |
|---|---|---|---|---|
| CONCELIER-CORE-AOC-19-001 `AOC write guard` | TODO | Concelier Core Guild | WEB-AOC-19-001 | Implement repository interceptor that inspects write payloads for forbidden AOC keys, validates provenance/signature presence, and maps violations to `ERR_AOC_00x`. |
| CONCELIER-CORE-AOC-19-001 `AOC write guard` | DONE (2025-10-29) | Concelier Core Guild | WEB-AOC-19-001 | Implement repository interceptor that inspects write payloads for forbidden AOC keys, validates provenance/signature presence, and maps violations to `ERR_AOC_00x`. |
> Docs alignment (2025-10-26): Behaviour/spec captured in `docs/ingestion/aggregation-only-contract.md` and architecture overview §2.
> Implementation (2025-10-29): Added `AdvisoryRawWriteGuard` + DI extensions wrapping `AocWriteGuard`, throwing domain-specific `ConcelierAocGuardException` with `ERR_AOC_00x` mappings. Unit tests cover valid/missing-tenant/signature cases.
> Coordination (2025-10-27): Authority `dotnet test` run is currently blocked because `AdvisoryObservationQueryService.BuildAliasLookup` returns `ImmutableHashSet<string?>`; please normalise these lookups to `ImmutableHashSet<string>` (trim nulls) so downstream builds succeed.
| CONCELIER-CORE-AOC-19-002 `Deterministic linkset extraction` | TODO | Concelier Core Guild | CONCELIER-CORE-AOC-19-001 | Build canonical linkset mappers for CVE/GHSA/PURL/CPE/reference extraction from upstream raw payloads, ensuring reconciled-from metadata is tracked and deterministic. |
| CONCELIER-CORE-AOC-19-002 `Deterministic linkset extraction` | DONE (2025-10-31) | Concelier Core Guild | CONCELIER-CORE-AOC-19-001 | Build canonical linkset mappers for CVE/GHSA/PURL/CPE/reference extraction from upstream raw payloads, ensuring reconciled-from metadata is tracked and deterministic. |
> 2025-10-31: Added advisory linkset mapper + DI registration, normalized PURL/CPE canonicalization, persisted `reconciled_from` pointers, and refreshed observation factory/tests for new raw linkset shape.
> Docs alignment (2025-10-26): Linkset expectations detailed in AOC reference §4 and policy-engine architecture §2.1.
| CONCELIER-CORE-AOC-19-003 `Idempotent append-only upsert` | TODO | Concelier Core Guild | CONCELIER-STORE-AOC-19-002 | Implement idempotent upsert path using `(vendor, upstreamId, contentHash, tenant)` key, emitting supersedes pointers for new revisions and preventing duplicate inserts. |
| CONCELIER-CORE-AOC-19-003 `Idempotent append-only upsert` | DONE (2025-10-28) | Concelier Core Guild | CONCELIER-STORE-AOC-19-002 | Implement idempotent upsert path using `(vendor, upstreamId, contentHash, tenant)` key, emitting supersedes pointers for new revisions and preventing duplicate inserts. |
> 2025-10-28: Advisory raw ingestion now strips client-supplied supersedes hints, logs ignored pointers, and surfaces repository-supplied supersedes identifiers; service tests cover duplicate handling and append-only semantics.
> Docs alignment (2025-10-26): Deployment guide + observability guide describe supersedes metrics; ensure implementation emits `aoc_violation_total` on failure.
| CONCELIER-CORE-AOC-19-004 `Remove ingestion normalization` | TODO | Concelier Core Guild | CONCELIER-CORE-AOC-19-002, POLICY-AOC-19-003 | Strip normalization/dedup/severity logic from ingestion pipelines, delegate derived computations to Policy Engine, and update exporters/tests to consume raw documents only. |
| CONCELIER-CORE-AOC-19-004 `Remove ingestion normalization` | DOING (2025-10-28) | Concelier Core Guild | CONCELIER-CORE-AOC-19-002, POLICY-AOC-19-003 | Strip normalization/dedup/severity logic from ingestion pipelines, delegate derived computations to Policy Engine, and update exporters/tests to consume raw documents only. |
> Docs alignment (2025-10-26): Architecture overview emphasises policy-only derivation; coordinate with Policy Engine guild for rollout.
| CONCELIER-CORE-AOC-19-013 `Authority tenant scope smoke coverage` | TODO | Concelier Core Guild | AUTH-AOC-19-002 | Extend Concelier smoke/e2e fixtures to configure `requiredTenants` and assert cross-tenant rejection with updated Authority tokens. | Coordinate deliverable so Authority docs (`AUTH-AOC-19-003`) can close once tests are in place. |
@@ -18,6 +21,7 @@
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| CONCELIER-POLICY-20-002 `Linkset enrichment for policy` | TODO | Concelier Core Guild, Policy Guild | CONCELIER-CORE-AOC-19-002, POLICY-ENGINE-20-001 | Strengthen linkset builders with vendor-specific equivalence tables, NEVRA/PURL normalization, and version range parsing to maximize policy join recall; update fixtures + docs. |
> 2025-10-31: Base advisory linkset mapper landed under `CONCELIER-CORE-AOC-19-002`; policy enrichment work can now proceed with mapper outputs and observation schema fixtures.
## Graph Explorer v1
@@ -49,8 +53,8 @@
| ID | Status | Owner(s) | Depends on | Notes |
|----|--------|----------|------------|-------|
| CONCELIER-GRAPH-24-001 `Advisory overlay inputs` | TODO | Concelier Core Guild | CONCELIER-POLICY-23-001 | Expose raw advisory observations/linksets with tenant filters for overlay services; no derived counts/severity in ingestion. |
> 2025-10-27: Initial prototype (query service + CLI consumer) drafted but reverted pending scope/tenant alignment; no changes merged.
| CONCELIER-GRAPH-24-001 `Advisory overlay inputs` | DONE (2025-10-29) | Concelier Core Guild | CONCELIER-POLICY-23-001 | Expose raw advisory observations/linksets with tenant filters for overlay services; no derived counts/severity in ingestion. |
> 2025-10-29: Filter-aware lookup path and /concelier/observations coverage landed; overlay services can consume raw advisory feeds deterministically.
## Reachability v1

Some files were not shown because too many files have changed in this diff Show More