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:
689
src/StellaOps.Api.OpenApi/authority/openapi.yaml
Normal file
689
src/StellaOps.Api.OpenApi/authority/openapi.yaml
Normal 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
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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 = "*";
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
public partial class Program
|
||||
{
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
@@ -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 (120 s OpTok, 300 s 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 (120 s OpTok TTL, 300 s 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 (120 s OpTok TTL, 300 s 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 120 s, 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. |
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
100
src/StellaOps.Cli/Services/Models/AocVerifyModels.cs
Normal file
100
src/StellaOps.Cli/Services/Models/AocVerifyModels.cs
Normal 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; }
|
||||
}
|
||||
30
src/StellaOps.Cli/Services/Models/PolicyActivationModels.cs
Normal file
30
src/StellaOps.Cli/Services/Models/PolicyActivationModels.cs
Normal 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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
35
src/StellaOps.Concelier.Core/Aoc/AdvisoryRawWriteGuard.cs
Normal file
35
src/StellaOps.Concelier.Core/Aoc/AdvisoryRawWriteGuard.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
16
src/StellaOps.Concelier.Core/Aoc/IAdvisoryRawWriteGuard.cs
Normal file
16
src/StellaOps.Concelier.Core/Aoc/IAdvisoryRawWriteGuard.cs
Normal 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);
|
||||
}
|
||||
308
src/StellaOps.Concelier.Core/Linksets/AdvisoryLinksetMapper.cs
Normal file
308
src/StellaOps.Concelier.Core/Linksets/AdvisoryLinksetMapper.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
3
src/StellaOps.Concelier.Core/Properties/AssemblyInfo.cs
Normal file
3
src/StellaOps.Concelier.Core/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Concelier.Core.Tests")]
|
||||
83
src/StellaOps.Concelier.Core/Raw/AdvisoryRawQueryOptions.cs
Normal file
83
src/StellaOps.Concelier.Core/Raw/AdvisoryRawQueryOptions.cs
Normal 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);
|
||||
19
src/StellaOps.Concelier.Core/Raw/AdvisoryRawRecord.cs
Normal file
19
src/StellaOps.Concelier.Core/Raw/AdvisoryRawRecord.cs
Normal 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);
|
||||
439
src/StellaOps.Concelier.Core/Raw/AdvisoryRawService.cs
Normal file
439
src/StellaOps.Concelier.Core/Raw/AdvisoryRawService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
37
src/StellaOps.Concelier.Core/Raw/IAdvisoryRawRepository.cs
Normal file
37
src/StellaOps.Concelier.Core/Raw/IAdvisoryRawRepository.cs
Normal 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);
|
||||
}
|
||||
56
src/StellaOps.Concelier.Core/Raw/IAdvisoryRawService.cs
Normal file
56
src/StellaOps.Concelier.Core/Raw/IAdvisoryRawService.cs
Normal 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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user