Integrations Architecture
The Integrations module is the central integration catalog and connector hub of the Stella Ops release control plane: the single, tenant-scoped place where connections to external tools — SCM providers, container registries, secrets managers, and CI systems — are registered, health-checked, and resolved for downstream modules.
Audience: engineers extending the connector catalog or consuming the integrations API, and operators onboarding external tools. This dossier covers the service runtime, data model, API surface, plugin admission, and the Secret Authority boundary. For the shared plugin runtime see the Plugin Framework architecture.
Overview
The Integrations module provides a unified service for managing connections to external tools (SCM providers, container registries, CI systems). It uses a plugin-based architecture where each external provider is implemented as a connector plugin, enabling new integrations to be added without modifying core logic. All integration state is persisted in PostgreSQL with tenant-scoped isolation.
Mounted connector runtime (updated 2026-06-04). External connector bundles are mounted read-only from devops/plugins/integrations/<profile>/<plugin-id>/ to /app/plugins/integrations/<profile>/<plugin-id>/; config mounts at /app/etc/plugins/integrations, trust roots at /app/trust-roots/plugins/integrations, and scratch/probe output belongs under /var/lib/stellaops/plugin-scratch/integrations. Cloud, proprietary, network-heavy, and SCM/CI provider sets are opt-in profile overlays, not base image payload.
The base mirror/storage connectors that stay in the WebService image are host-owned bootstrap behavior for offline and local registry operations. Their probe rows use profile image, remain visible for diagnostics, and are non-required for mounted-plugin acceptance. Profile-mounted connectors remain required once declared by compose and must pass manifest, signature, load, and probe admission.
Design Principles
- Plugin extensibility - New providers are added as plugins, not core code changes
- Credential indirection - Secrets are referenced via AuthRef URIs pointing to external vaults; the integration catalog never stores raw credentials
- Tenant isolation - Every integration record is scoped to a tenant; queries are filtered at the data layer
- Health-first - Every integration has a health check contract; the latest probe result is durable on the catalog row
Components
Integrations/
├── StellaOps.Integrations.WebService/ # HTTP API (Minimal API), IntegrationService, plugin loader, secret broker
├── StellaOps.Integrations.PluginHost.ProcessRunner/ # Out-of-process child runner for sandboxed external connectors
├── __Libraries/
│ ├── StellaOps.Integrations.Contracts/ # Shared DTOs, IIntegrationConnectorPlugin, IIntegrationCatalog seam
│ ├── StellaOps.Integrations.Core/ # Enums, IntegrationConfig, lifecycle events, DORA RoI metadata
│ ├── StellaOps.Integrations.Persistence/ # PostgreSQL via IntegrationDbContext + embedded SQL migrations
│ ├── StellaOps.Integrations.Services/ # Cross-host service helpers
│ └── StellaOps.Integrations.DockerV2/ # Docker Registry v2 client primitives
├── __Plugins/ # Connector implementation source projects
│ ├── StellaOps.Integrations.Plugin.GitHubApp/ # Profile-bundled GitHub App / GitHub Enterprise connector
│ ├── StellaOps.Integrations.Plugin.GitLab/ # Profile-bundled GitLab Server / GitLab CI connector
│ ├── StellaOps.Integrations.Plugin.Gitea/ # Profile-bundled Gitea SCM connector
│ ├── StellaOps.Integrations.Plugin.Harbor/ # Profile-bundled Harbor registry connector
│ ├── StellaOps.Integrations.Plugin.DockerRegistry/ # Profile-bundled Docker Registry / GitLab Container Registry connector
│ ├── StellaOps.Integrations.Plugin.Jenkins/ # Jenkins CI connector
│ ├── StellaOps.Integrations.Plugin.Nexus/ # Nexus repository connector
│ ├── StellaOps.Integrations.Plugin.Vault/ # HashiCorp Vault secrets-manager connector
│ ├── StellaOps.Integrations.Plugin.Consul/ # Consul config connector
│ ├── StellaOps.Integrations.Plugin.EbpfAgent/ # eBPF runtime-host agent connector
│ └── StellaOps.Integrations.Plugin.InMemory/ # In-memory test double (NOT in the built-in catalog; test fixtures only)
├── __Extensions/ # Non-.NET IDE extensions (see "IDE Extensions" below)
│ ├── vscode-stella-ops/ # VS Code extension (TypeScript)
│ └── jetbrains-stella-ops/ # JetBrains plugin (Kotlin)
└── __Tests/ # xUnit test projects + process-isolation plugin fixtures
├── StellaOps.Integrations.Tests/
├── StellaOps.Integrations.Plugin.Tests/
└── StellaOps.Integrations.DockerV2.Tests/
Note: the
InMemoryconnector lives under__Plugins/but is deliberately excluded fromIntegrationBuiltInPluginCatalog.GetAssemblies(); the running WebService never loads it. It is referenced only by explicit test harnesses. Docker Registry / GitLab Container Registry now follows the same runtime-composition rule as the other external connectors: the source project can be built as a signed bundle, butintegrations-webdoes not compile or load it from the image.
Data Flow
[External Tool] <── health check ─────────── [Integrations WebService]
│
[Client Module] ── GET /api/v1/integrations ──> │
│
[Admin/CLI] ── POST /api/v1/integrations ─────> │ ── [IntegrationDbContext] ── [PostgreSQL]
│
[Plugin Loader] ── load built-in + admit external ─│ ── [Plugin Framework]
- Registration: An administrator or automated onboarding flow creates an integration record via
POST /api/v1/integrations, providingtype/provider,endpoint, optionalextendedConfig, and — at most one of — an inline secret (inlineSecret+credentialKind) or an externalauthRefUri. Registry-typed integrations must carry credentials. - Discovery: The
IntegrationPluginLoaderregisters the host-owned provider catalog (WebService-local feed/object-store probes only) and, only when enabled, verifies external directory candidates before retaining provider descriptors in memory for lookup and failure reporting. In production, external directory admission requires signature trust, declared connector capabilities, a healthystellaops.pluginhost.process.v1child-runner handshake, and child-runner metadata validation. External DLLs are not loaded into the WebService process; accepted external candidates are exposed as process-backed connector proxies. - Health checking: The API invokes each integration’s provider-specific health check and persists the latest result (
last_health_status,last_health_check_at) even when the status is unchanged. Background polling ships and is enabled by default:IntegrationHealthMonitor(aBackgroundServiceregistered atProgram.cs:407) sweeps everyStatus==Active && !IsDeletedintegration across all tenants viaIGlobalIntegrationRepositoryand calls the sameCheckHealthAsyncper row. Defaults (IntegrationHealthMonitorOptions, bound fromIntegrations:HealthMonitor):Enabled=true,Interval=5m(clamped to a floor),InitialDelay=1m. Offline/air-gap note: because health probes are outbound calls to the configured provider endpoints, a sealed site that does not want periodic egress attempts must setIntegrations:HealthMonitor:Enabled=false. Health-history tables and alert emission remain target capabilities (the monitor overwrites the latest status and emitsIntegrationHealthChangedEventin-process; it does not append history rows). - Consumption: Downstream modules query the catalog filtered by tenant: human/operator callers list via
GET /api/v1/integrations?type={type}(there is no/integrations/{type}path segment), and cross-module consumers that hold the integrations data (e.g. Scanner’s bulk-import) resolve a single integration through the in-process read-onlyIIntegrationCatalogseam rather than HTTP. Release-orchestrator (and Scanner.Worker / SbomService) fetch the credential viaPOST /api/v1/integrations/{id}/resolve-credentials, which always returns it AEAD-sealed (sealedSecret) and unseal it in memory — no plaintext credential travels on the wire (SPRINT_20260704_002 / CUS-3). - Registry auto-scan (Sprint 20260607_001, AUTO-001/004/005): when a registry integration becomes
Active(on add/connect, and via a periodic catch-up sweep) its repositories/tags are enumerated, each tag is resolved to its image digest, and a discovery row is appended tointegrations.registry_image_discoveries. The Scanner.WorkerRegistryImageDiscoveredBridgeServiceconsumes that outbox and submits a readiness-gated scan per digest — so SBOM-readiness auto-populates and the deploy gate stops blocking on missing SBOMs, with no manualPOST /api/v1/scans. See “Registry auto-scan” below.
Registry auto-scan (close the SBOM-readiness gap)
RegistryImageEnumerator is provider-aware:
Generic OCI (DockerHub / registry:2 / Harbor):
/v2/_catalog→/v2/{repo}/tags/list→ manifest HEAD (Docker-Content-Digest) to resolve the digest, reusing the connector’s Distribution v2 Bearer-challenge dance (StellaOps.Integrations.DockerV2).GitLab CE (GitLab Container Registry): the GitLab registry API —
GET /api/v4/groups/{group}/registry/repositories(paginated) →GET /api/v4/registry/repositories/{id}/tags→ per-tag…/tags/{name}whose detail carriesdigest. GitLab CE denies/v2/_catalogto non-admin PATs, so the catalog endpoint is never used for GitLab; the group is read fromExtendedConfig.gitlabGroupPath/gitlabGroupId(without it, enumeration is skipped, logged).JFrog Artifactory: tries the Docker Registry v2 catalog first. If the Artifactory Docker endpoint returns
404for_catalog, the connector and auto-scan enumerator fall back to the Artifactory storage API using non-secretExtendedConfig.artifactoryRepositoryand/orExtendedConfig.artifactoryStorageApiBase. The fallback tries the Pro?list&deep=1&listFolders=0API first and, when Artifactory OSS returns400/403, walks the ordinary storage folder tree before continuing through Docker Registry v2 tag and manifest HEAD paths for digest resolution.
Enumeration is bounded (MaxReposPerRegistry, MaxTagsPerRepo), deterministically ordered (repo asc, tag asc), honours ExtendedConfig.scanRepoPattern / scanTagPattern globs, and is fail-soft (a single repo/tag failure is logged and skipped). A registry that is Failed/anonymous/credential-unresolvable is never enumerated (S046-001).
The on-connect path (RegistryAutoScanOnConnectTrigger) is fire-and-forget off the request hot path (a detached scoped task) so it never changes the create/test latency or status. The periodic sweep (RegistryAutoScanSweepService, modelled on RegistryWriteDriftMonitor) enumerates active registries cross-tenant via IGlobalIntegrationRepository.GetActiveByTypeAcrossAllTenantsAsync; it is disabled by default (opt-in) and capped by MaxIntegrationsPerSweep + a global MaxImagesPerSweep so one large registry cannot flood the scan queue (remaining images roll to the next sweep). Dedupe across the on-connect path and the sweep is owned by the Scanner-side deterministic scan id, not by the outbox.
Config keys — service options under Integrations:RegistryAutoScan: Enabled (on-connect default, default true), MaxReposPerRegistry, MaxTagsPerRepo, DefaultRepoPattern, DefaultTagPattern, and Sweep:{Enabled (default false), Interval, InitialDelay, MaxIntegrationsPerSweep, MaxImagesPerSweep}. Per-integration ExtendedConfig overrides: autoScanOnConnect (bool, overrides the service default both ways), scanRepoPattern, scanTagPattern, gitlabGroupPath/gitlabGroupId (GitLab only).
For Artifactory storage API fallback, per-integration ExtendedConfig may include artifactoryRepository and/or artifactoryStorageApiBase; these are non-secret routing hints and are forwarded to the process-isolated connector runner.
Registry provider descriptors and matrix
Registry onboarding is descriptor-driven. StellaOps.Integrations.Core.RegistryProviderDescriptor is the source contract for registry tiles and IRegistryProvider is the marker implemented by registry connector plugins. The descriptor carries the stable descriptorId, provider enum, friendly display name, default endpoint and hint, accepted authModes, discovery strategy, OCI write-probe flag, setup-document link, local test image hint, connector name, and optional cloud token-exchange note.
GET /api/v1/integrations/providers projects these descriptors into ProviderInfo. The console merges that backend metadata onto its presentation definitions keyed by descriptorId, so auth fields and setup links come from the descriptor catalog instead of hard-coded FE guesses. Auth modes are wire-compatible with the existing credential contract: anonymous sends no credential, basic composes username:secret, and token sends the bare bearer/PAT value.
| Descriptor id | Display name | Provider enum | Auth modes | Discovery strategy | Connector | Notes |
|---|---|---|---|---|---|---|
acr | Azure Container Registry | Acr | basic | provider_api | acr-registry | BCL-only AAD client-credentials plus ACR token exchange; endpoints are overridable for offline/local proof. |
dockerhub | Docker Hub | DockerHub | anonymous, basic, token | provider_api | docker-registry | Shares the generic Docker connector but remains a distinct tile by descriptor id. |
ecr | AWS ECR | Ecr | basic | provider_api | ecr-registry | BCL-only SigV4 GetAuthorizationToken; region and endpoint are overrideable through ExtendedConfig. |
gcr | Google Artifact Registry / GCR | Gcr | basic | provider_api | gcr-registry | BCL-only service-account JWT to OAuth2 exchange; token endpoint is overrideable through ExtendedConfig. |
ghcr | GitHub Container Registry (GHCR) | GitHubContainerRegistry | anonymous, basic, token | provider_api | ghcr-registry | Provider-native catalog metadata; OCI operations still use the registry connector path. |
gitlab-cr | GitLab Container Registry | GitLabContainerRegistry | basic, token | oci_catalog | gitlab-container-registry | GitLab-bundled registry connector; group/project API enumeration is used where _catalog is not available. |
harbor | Harbor | Harbor | basic, token | oci_catalog | harbor-registry | Robot-account friendly; uses OCI catalog/tag enumeration for image discovery. |
oci-distribution | Generic OCI Distribution (registry:2 / distribution) | DockerHub | anonymous, basic, token | oci_catalog | docker-registry | Local/self-hosted registry tile; supports the non-destructive OCI write probe. |
quay | Quay | Quay | basic, token | oci_catalog | quay-registry | Robot-account friendly; shares generic OCI behavior. |
artifactory | JFrog Artifactory | Artifactory | basic, token | oci_catalog with Artifactory storage API fallback | artifactory-registry | Docker repository endpoint supplied by the operator; set artifactoryRepository and/or artifactoryStorageApiBase when _catalog is unavailable. |
nexus | Sonatype Nexus | Nexus | basic, token | oci_catalog | nexus-registry | Docker repository endpoint supplied by the operator. |
zot | Zot (CNCF OCI-native) | Zot | anonymous, basic, token | oci_catalog | zot-registry | Local Stella Ops registry proof path. |
The cloud rows are implemented without AWS, Google, or Azure SDK dependencies. The token endpoints are overrideable so local labs can attempt provider-compatible emulators and so unit tests can assert the exact request/signature shapes. When no provider-compatible emulator or cloud credential is available, the strongest local runtime proof is an authenticated OCI Distribution endpoint using a provider-shaped, already-exchanged Docker-login credential (AWS:<password>, oauth2accesstoken:<token>, or the ACR null-guid username). That proves the Stella Ops descriptor, credential materialization, OCI read, discovery, and auto-scan paths, but it is not AWS/GCP/Azure managed control-plane proof. The S020 retry against the local test-ecr LocalStack container returned HTTP 501 for GetAuthorizationToken, so no ECR managed-control-plane proof is claimed.
The local compose QA fixtures include S020 OCI Distribution services under devops/compose/docker-compose.integration-fixtures.yml with htpasswd files in devops/compose/fixtures/integration-fixtures/registry-providers/. They provide the test-harbor, s020-harbor-registry, s020-dockerhub-oci, s020-ghcr-oci, s020-ecr-oci, s020-gcr-oci, and s020-acr-oci network aliases used by the provider matrix evidence. Seed them with real image manifests before discovery; the S020 proof copied library/alpine:latest from the existing local test-registry-basic:5000 registry. s020-harbor-registry runs the real Harbor registry-photon binary for the OCI Distribution component only. Full Harbor application proof is covered separately in docs/qa/feature-checks/runs/2026-06-23-s020-harbor-full-local-app-proof/: the run pulled official goharbor/prepare:v2.11.0, generated a disposable Harbor v2.11 compose/config under ignored local runtime/, started core/portal/db/jobservice/redis/nginx/registry/registryctl/log, seeded s020/alpine:full-local, and verified Stella Ops Harbor /test, /discover, and image browse against http://host.docker.internal:18085. The committed evidence keeps command output and summaries only; generated DB, key, and test-credential material remains ignored.
Database Schema
PostgreSQL. The default connection string targets database stellaops_integrations, and EF Core / the embedded SQL migrations own the integrations schema (IntegrationDbContext.DefaultSchemaName = "integrations"). Migrations are applied on startup via AddStartupMigrations("integrations", "Integrations.Persistence", …).
| Table | Purpose |
|---|---|
integrations.integrations | Primary module-owned catalog table. Columns: id (UUID PK), name, description, type, provider, status, endpoint, auth_ref_uri, organization_id, config_json (JSONB), last_health_status, last_health_check_at, created_at, updated_at, created_by, updated_by, tenant_id, tags (JSONB), is_deleted. Inline-credential columns (migrations 003/004): credential_backend, credential_kind, credential_ciphertext, credential_nonce, credential_kek_id, credential_kek_version, credential_alg, credential_metadata. Registry-policy column (migration 006): requires_credentials_resolution. The type, provider, status, and last_health_status enum columns are stored as TEXT (the enum member name, e.g. Scm/GitLabServer/Active), not as integers. |
integrations.registry_image_discoveries | Registry auto-scan outbox (migration 002_v1_registry_image_discoveries.sql, Sprint 20260607_001). Append-only rows of (integration_id, tenant_id, registry_host, repository_path, tag, image_digest, discovered_at, source). Written by the auto-scan enumeration on registry connect + the periodic sweep; polled cross-schema by Scanner.Worker RegistryImageDiscoveredBridgeService to submit readiness-gated scans. A unique index on (tenant_id, registry_host, repository_path, image_digest) + ON CONFLICT DO NOTHING collapses repeat discovery of the same image; dedupe of the resulting scans is owned by the deterministic Scanner scan id, not by this table. |
Indexes: ix_integrations_type, ix_integrations_provider, ix_integrations_status, ix_integrations_tenant_id, a partial unique index ix_integrations_tenant_name over (tenant_id, name) WHERE is_deleted = false, and a partial ix_integrations_requires_creds_resolution. Delete is soft (is_deleted = true). Migration 004_v1_registry_image_discovery_integration_stamp.sql adds discovery browse indexes on (tenant_id, integration_id, repository_path) and (tenant_id, image_digest) for the registry/image browser read paths.
Tenant-isolation invariant (SPRINT_20260531_035, class C1 fix)
Every read / mutate against integrations.integrations MUST be tenant-scoped. This is enforced in two layers:
- Application layer (primary defence) —
IIntegrationRepository. Every method that touches a tenant-owned row carries a requiredtenantIdparameter that is applied as aWHERE tenant_id = @tenantIdpredicate in SQL.BuildQueryrejects a null/emptyIntegrationQuery.TenantIdwithArgumentException(no silent cross-tenant fallthrough). Bypass for genuinely system-wide scans (e.g. health workers) is the explicitIGlobalIntegrationRepositoryseam, so cross-tenant intent is auditable in DI rather than smuggled through a magic-null id. A cross-tenantUpdateAsyncthrowsIntegrationNotFoundException(mapped to HTTP 404 at the API surface) — never anInvalidOperationExceptionthat would leak row existence. - Database layer (defence-in-depth) — migration
007_tenant_isolation_rls.sql. PostgreSQL Row-Level Security is enabled (not forced) onintegrations.integrations, with a single policyintegrations_tenant_isolationkeyed oncurrent_setting('app.current_tenant_id', true). Table owners and BYPASSRLS roles continue to bypass per the standard pattern (mirrorsrelease.integrationsreference atMigrations/Release/001_IntegrationHub.sql:196), so the migration is safe to land on running services — only future non-owner callers that bypass the repository would meet the policy. Theapp.current_tenant_idsession variable is the same GUC used elsewhere in the platform (seeshared.actor_identityin076_ActorIdentity.sql).
Why both layers? Hunter #1 (multi-tenancy audit, 2026-05-31) confirmed that the application-layer filter had been missing on 7 methods (GetByIdAsync, UpdateAsync, DeleteAsync, GetByProviderAsync, GetActiveByTypeAsync, UpdateHealthStatusAsync, BuildQuery’s optional-tenant fallthrough). For the highest-severity instance — UpdateAsync — tenant A could rewrite tenant B’s config_json/auth_ref_uri/credential_ciphertext columns and trigger health-checks under their own worker simply by knowing the row id. The single-layer fix would have been adequate but would re-expose the same class the next time a caller appears that doesn’t go through the repository wrapper. Belt + braces.
Regression coverage: PostgresIntegrationRepositoryTenantIsolationTests (Testcontainers Postgres, one test per fixed method, asserts returned-row tenant_id matches the caller’s, never != null).
The KEK control plane (crypto.kek_versions) and the credential re-seal progress tables are owned by StellaOps.Cryptography.CredentialStore and registered for this service via AddCryptoKekVersionsMigrations("Integrations") / AddCredentialReSealMigrations("Integrations"); they are shared infrastructure, not Integrations-local tables.
The /internal/crypto/* routes are installation-wide control-plane operations, not tenant catalog APIs. They still require an authenticated principal: reads accept crypto:kek:read, crypto:kek:rotate, or ops.admin; start/abort accept crypto:kek:rotate or ops.admin. Platform forwards a validated operator Bearer token or relays the gateway-signed identity envelope after Platform verified it as StellaRouterEnvelope; Integrations verifies/re-authorizes the identity again. Network bypass alone cannot authorize these routes. Base compose accepts both api://integrations and first-party stellaops audiences and reaches the service on port 8080. The default compose transport remains internal HTTP, so this boundary must not be described as mTLS until direct certificate-bound listeners and clients are provisioned.
DORA Register of Information metadata
Connection profiles can carry optional doraRoiMetadata for DORA Register of Information projections. The metadata is stored as JSONB on the catalog row and is included in create/update/read DTOs so downstream RoI encoders can project integrations without scraping provider-specific configuration.
Fields:
criticalityLevelwithcriticalityLevelProvenance.contractStartDatewithcontractStartDateProvenance.contractEndDatewithcontractEndDateProvenance.subContractingChain[], where each entry hasrank,providerName, optionalproviderIdentifier, optional ISO 3166-1 alpha-2countryCode, andprovenance.
Provenance is explicit. operatorFilled is valid for operator-entered fields and all deeper subcontracting ranks. autoCapturedDepth1 is valid only for the direct provider at subContractingChain.rank == 1; ranks greater than 1 must remain operator-filled. This implements the approved DORA-Q3 decision that Stella auto-discovers only direct integrations and must not imply automatic knowledge of deeper supply chains.
The Web Integration Hub treats this as optional module metadata, not as a global product mode. During integration onboarding, operators can opt in to attach DORA RoI metadata on the discovery-scope step. The Console sends scalar criticality and contract-date fields with operatorFilled provenance, allows autoCapturedDepth1 only for rank-1 subcontracting rows, forces deeper ranks to operatorFilled, and shows stored metadata read-only on the integration overview. Profiles without RoI metadata remain unchanged.
The shipped schema does not include module-owned health_checks, audit_logs, or plugin_metadata tables. The service persists the latest health result on integrations; mutable endpoint audit is emitted through the shared unified audit pipeline and structured service logs. Historical health retention and an Integrations-local audit store require separate schema work.
Endpoints
Registry browse endpoints are additive under /api/v1/integrations/registries, require tenant context, and use Integration.Registry.Read -> registry:read (StellaOpsScopes.RegistryRead).
All endpoints below are mapped under the /api/v1/integrations group in IntegrationEndpoints.cs. The group requires authorization and a tenant context (RequireAuthorization(IntegrationPolicies.Read).RequireTenant()), and each route additionally pins a per-operation policy. The three policies map to canonical scopes: Integration.Read → integration:read, Integration.Write → integration:write, Integration.Operate → integration:operate (see Program.cs AddStellaOpsScopePolicy wiring and StellaOpsScopes.IntegrationRead/Write/Operate). Route IDs use {id:guid} constraints.
Integration CRUD (/api/v1/integrations)
GET /— List integrations for the current tenant. Query parameters:type,provider,status(enums),search,page(default 1),pageSize(default 20),sortBy(defaultname),sortDescending(default false). Defined numeric enum values remain accepted for operator compatibility (type=5isRuntimeHost); undefined numeric values are rejected with a sealed RFC-7807400rather than being treated as valid empty filters. Returns aPagedIntegrationsResponse(items,totalCount,page,pageSize,totalPages). Scope:integration:read.GET /{id}— Get integration by ID.404outside the caller’s tenant scope. Scope:integration:read.POST /— Register a new integration. Request body carries exactly ONE ofinlineSecret(withcredentialKind) ORauthRefUri, or neither for a genuine no-credentials integration. TheinlineSecret/authRefUripair is mutex-validated server-side and the resulting row lands in one of three canonical shapes (see “Credential backends” below). Registry-typed integrations registered without any credential are rejected with HTTP 400anonymous_registry_not_allowed(AnonymousRegistryNotAllowedException). When a connector plugin exists for the provider, the service auto-runs a test connection and may flip the row toActive/Failed. Returns201 Created. Scope:integration:write.PUT /{id}— Update integration configuration (same credential-shape and registry-credential rules). An update that touches neither credential field leaves the existing backend untouched. Scope:integration:write.DELETE /{id}— Soft-delete the integration (is_deleted = true) with audit; returns204 No Content(or404). Scope:integration:write.
Operations
POST /{id}/test— Run an on-demand provider test-connection, updatestatus(Active/Failed) when it changes, and emit atest/integration.test_connectionaudit event. ReturnsTestConnectionResponse. Scope:integration:operate.POST /{id}/discover— Discover provider resources for aresourceType(e.g.repositories,projects,pipelines,jobs,tags). Body:{ resourceType, filter? }. Returns200(DiscoverIntegrationResponse),404(not found),400(unsupported resource type, withsupportedResourceTypes), or502on connector failure. Scope:integration:operate.POST /{id}/resolve-credentials— Trusted in-cluster only. Resolves the credential and always returns it AEAD-sealed — no plaintext credential ever travels on the wire (SPRINT_20260704_002 / CUS-3).ResolveIntegrationCredentialsResponse:integrationId,credentialBackend,credentialKind,resolvedAt, andsealedSecret(the sealed envelope;nullwhen the integration resolves to no credential material). Every consumer (release-orchestrator’sHttpRegistryCredentialResolver, Scanner.Worker, and SbomService) unsealssealedSecretin memory and fails closed when the envelope or response-domain KEK is absent. The former plaintextsecretmember and consumer downgrade branches have been physically removed. A legacy?seal=truequery flag is harmless but ignored because sealing is unconditional. The secret is never logged or written to the audit row. Scope:integration:operate.GET /{id}/impact— Static impact map: which downstream workflows are affected by this integration’s status, withseverity,blockingWorkflowCount, andimpactedWorkflows[]. Scope:integration:read.POST /ai-code-guard/run— Standalone AI Code Guard run (AiCodeGuardRunRequest:owner,repo,commitSha, optionalconfigYaml,files[]). Scope:integration:operate.
Registry browse (/api/v1/integrations/registries)
GET /api/v1/integrations/registries- Tenant-scoped registry posture over persisted discovery rows. ReturnsRegistryPostureResponse[]withintegrationId, displayname,registryHost, nullableslug,imageCount,repositoryCount,lastDiscoveredAt,evaluatedImages, nullablecriticalCount, nullablehighCount, and nullablereachabilityPosture. A discovery summary with no active tenant-scoped integration is omitted before digest security lookup; the remaining rows preserve discovery order. Integration metadata is loaded in one tenant-scoped registry query rather than one lookup per summary. Security fields are projected throughIRegistryImageVulnStateProviderby digest; the defaultNullRegistryImageVulnStateProviderreturns no rows, so nullable fields mean genuinely unevaluated rather than fabricated zero findings. Scope:registry:read.GET /api/v1/integrations/registries/{id}/images- Tenant-scoped keyset-paginated discovery rows for one registry integration. Query parameters:pageSize(default 50, capped at 200) andafterId(default 0). ReturnsRegistryImagePagedResponsewith page-localtotalCount,nextAfterId, and items carrying repository/tag/digest plus both presentation references:maskedRef(stella+oci://registries/<slug-or-guid>/repo@digest) andrealRef(host/repo@digest). Each image row also carries nullable legacy security fields (criticalCount,highCount,mediumCount,sbomReadiness,reachabilityPosture,hasDecision,verdict,lastScanAt) plus additive Scanner v2 fields (readiness,scanStatus,lastAttemptAt,lastSuccessfulScanAt). A missing provider row leaves those fields null to preserve honest unevaluated state. A registry id outside the caller tenant returns404. Scope:registry:read.
When a Scanner storage connection string can be resolved from ScannerStorage:Postgres:ConnectionString, ConnectionStrings:Default, or ConnectionStrings:IntegrationsDb, the WebService registers ScannerRegistryImageVulnStateProvider instead of the policy-only fallback. Only this Scanner-DSN branch registers IScanRuntimeStateRepository, IArtifactBomRepository, and the validated Scanner:SbomReadiness:SuccessfulScanMaxAge option (positive, default 14.00:00:00). The read-only provider joins the latest runtime attempt, latest durable BOM, public reachability verdicts, severity counts, and policy decision by tenant + image digest. Per-digest Scanner reads execute with a fixed concurrency bound (ScannerRegistryImageVulnStateProvider.MaxConcurrentDigestLookups) and results are reassembled in requested-digest order; this prevents database-latency multiplication without allowing unbounded connection fan-out. Durable BOM age maps to v2 readiness=ready|stale; a successful lookup with no BOM maps to absent. Exact attempt aliases map to pending, running, succeeded, failed, or cancelled, with the attempt timestamp kept separate from lastSuccessfulScanAt. A BOM-only row remains visible. Legacy sbomReadiness and lastScanAt retain their previous attempt-derived mapping byte-for-byte. A non-cancellation durable-BOM repository failure is logged and degrades only v2 readiness to null (unknown/unavailable), while runtime attempt, policy, severity, and reachability enrichments remain usable; request cancellation still propagates.
Policy gate decisions are joined through a separate read-only adapter over policy.gate_decisions, resolved from PolicyStorage:Postgres:ConnectionString, Policy:Postgres:ConnectionString, ConnectionStrings:PolicyDb, ConnectionStrings:Default, or the Integrations connection string fallback. The adapter resolves tenant slugs through shared.tenants, sets app.current_tenant_id, and reads the latest row per lower-cased image_digest by evaluated_at/decision_id. Policy statuses map to registry-browser verdicts as pass/allow -> allow, warn/review -> warn, and fail/block/deny -> block; unknown statuses remain unknown. When Scanner storage is unavailable, PolicyOnlyRegistryImageVulnStateProvider still exposes hasDecision=true and verdict for matching digest rows while scan and reachability fields stay null. This is an explicit short-term cross-domain read projection for the image browser; a future signed OCI referrer or Policy-owned projection may supersede it without changing the browser response shape.
Credential backends (explicit, post-Sprint-032)
Every integration row carries an explicit credentialBackend discriminator in one of three canonical shapes. The legacy credential_backend='none' + non-null auth_ref_uri shape was removed in Sprint 032 (no released version carried it); registration rejects it with HTTP 400 and migration 005 backfilled any residual rows to authref.
| Backend | Request shape | Row shape | When to use |
|---|---|---|---|
inline | inlineSecret + credentialKind, no authRefUri | AEAD-sealed ciphertext (credential_* columns), auth_ref_uri NULL | Stella-managed credential storage. Canonical for the WS-7 onboarding flow (see 01b-register-inline-integrations.sh). |
authref | authRefUri only, no inlineSecret | auth_ref_uri set, inline ciphertext NULL | Operator wants the secret to live in an external vault (HashiCorp Vault, Azure Key Vault, etc.) and resolve at runtime. |
none | Neither inlineSecret nor authRefUri | credential_backend='none' + all credential columns + auth_ref_uri NULL | Genuine anonymous-access integration (no credentials configured). |
Resolution dispatch is unified across both code paths (IntegrationService.BuildConfigAsync for test/health/discover, and IntegrationCredentialResolver.ResolveAsync for release-orchestrator’s sync-versions): both run the same explicit-backend switch with no default back-compat dispatch. Release-orchestrator’s HttpRegistryCredentialResolver always dispatches to integrations-web’s /resolve-credentials (the none-backend short-circuit was removed in Sprint 032 because it defeated the integrations-web back-compat for sync-versions).
DB invariant: ck_integrations_credential_backend CHECK constraint (migration 005) rejects any row that doesn’t match one of the three shapes above. ADR-004 forward-only; downgrade is via PostgreSQL snapshot restore.
Health (/api/v1/integrations)
GET /{id}/health— Trigger an on-demand provider health check, return its current result (HealthCheckResponse), and persist the latest status/timestamp (last_health_status,last_health_check_at) on the integration row viaUpdateHealthStatusAsync, even when unchanged. Emits anIntegrationHealthChangedEventonly when the status actually transitions. When no plugin is available or the connector lacks theintegration:health-checkcapability, the result isUnknown. Scope:integration:read.
Runtime health
GET /healthGET /health/livenessGET /health/readiness
All three runtime aliases call the same IntegrationHealth handler and return { status: "Healthy", timestamp }. They are anonymous (AllowAnonymous) container/orchestrator probes and do NOT trigger provider checks; provider health remains under /api/v1/integrations/{id}/health.
Provider discovery
GET /providers— List the connector providers loaded into the current WebService process (ProviderInfo[]:name,type,provider,isTestOnly,supportsDiscovery,supportedResourceTypes). Optional?includeTestOnly=trueincludes theInMemorytest provider (excluded by default). There is no public endpoint that mutates plugin discovery state. Scope:integration:read.
JSON wire shape (request + response)
Sprint SPRINT_20260528_033 (commits 6cb43f2711 + a3a7384bab) restored the legacy operator contract for enum fields on POST/PUT /api/v1/integrations:
Enum fields (
type,provider,status) accept BOTH string and INT representations. The minimal-API JSON pipeline registersJsonStringEnumConverterwithCamelCasenaming andallowIntegerValues=true, so all four wire shapes below resolve to the same enum member:Shape Example Notes PascalCase "type": "Scm", "provider": "GitLabServer"The conventional name in the StellaOps.Integrations.Core.IntegrationEnumssource. Preferred for human-readable operator scripts (01-register-integrations.ps1).camelCase "type": "scm", "provider": "gitLabServer"Matches OpenAPI-generated typed clients and the convention used by sibling services (Concelier, PacksRegistry). lowercase "type": "scm", "provider": "custom"Common when shell-env values are forwarded unchanged ( type=$TYPEetc.).INT "type": 2, "provider": 201Back-compat with Sprint 029 FIX-003’s 01b-register-inline-integrations.sh. The INT values come fromIntegrationEnums.cs(Scm=2,GitLabServer=201, …).Operator preference: string-PascalCase is recommended for new scripts (matches the source-of-truth enum names and is stable across future enum-member additions). INT representation is fragile — inserting a new enum member in the middle of the numeric range shifts subsequent values and silently breaks operator scripts.
HTTP 400 responses carry an RFC-7807 ProblemDetails body. Validation failures (malformed JSON, unknown enum value, mutex violations) return a JSON body shaped like:
{ "type": "https://stella-ops.local/probs/bad-request", "title": "Invalid request", "status": 400, "detail": "The request body or parameters are invalid.", "traceId": "00-58ccc5c6691828d01577ee0e26d37e32-2d50cfb62ddcf182-00", "service": "integrations" }The
traceIdlets operators correlate the 400 with the request trace. Theservicefield is always"integrations". Thedetailfield is stable host-authored text; handled binder and parser exception diagnostics are suppressed and never returned because they can contain submitted values.HTTP 500 responses do not expose exception details. Unexpected server failures return a stable generic
detail; the exception message, stack, and inner exceptions are not serialized to the caller. Operators correlate the failure with server-side type-only logging through the responsetraceId. This boundary applies even when the service is reachable only on an internal network, because downstream exceptions can contain credentials, connection strings, remote payload fragments, or host paths.Empty 400 bodies are a regression. Prior to Sprint 033 the minimal-API binder returned 400 with
Content-Length: 0, giving operators zero debugging signal. If you see an empty 400 body on this service, the Sprint 033 image has not rolled — file a follow-up rather than reverting the operator script to INT enums.
Bulk SCM import
Sprint SPRINT_20260514_001 adds a bulk import primitive so an operator can onboard an entire SCM org in one call. The endpoints live in the Scanner WebService (not Integrations), because the work product is a fan-out of SBOM source registrations + scan triggers:
POST /api/v1/scm/{integrationId}/bulk-import- body{repos: [{name, cloneUrl, branch?}], maxConcurrent}. Validates that{integrationId}resolves to an SCM-typed integration, registers oneGitSBOM source per repo, triggers a scan per source, and returns202{batchId, repoCount, status}. A single repo failing (CreateAsync/TriggerScanAsync) is isolated and never aborts the batch.GET /api/v1/scm/imports/{batchId}- per-repo status + counts (succeeded/failed/pending), the liveinFlightgauge, and themaxConcurrentcap. Returns404for unknown batches.
IIntegrationCatalog seam
Scanner.WebService references StellaOps.Integrations.Contracts only - not Persistence / WebService. To classify an integration without a write-side dependency, Contracts now exposes a read-only IIntegrationCatalog (GetIntegrationAsync(id) / IsScmIntegrationAsync(id)). The Postgres-backed IntegrationCatalog implementation lives in StellaOps.Integrations.Persistence and is registered by hosts that own the integrations data (AddIntegrationCatalog()). Scanner.WebService registers a fail-safe NullIntegrationCatalog default via TryAddSingleton, so a host wiring the real catalog wins; on its own the bulk-import endpoint fails closed (404) rather than fanning out against an unverified integration.
Throttling
Concurrency is bounded by an in-process SemaphoreSlim sized to maxConcurrent (clamped to [1, 64], default 8). The batch lifecycle and the in-flight gauge are tracked in an in-memory BatchStatusStore. A Valkey-backed distributed throttle is a future sprint - the Scanner test host strips the Valkey subscriber, so in-process is the correct default today.
CLI
stella scm import --integration <id> --file repos.txt --max-concurrent 20reads a TSV repo list (name<TAB>cloneUrl[<TAB>branch]), POSTs the bulk endpoint, polls every 5s until terminal, and exits0only when every repo succeeded.stella scm import-status --batch <id>is a one-shot batch status read.stella scm list-reposis blocked pending a connector contract:IIntegrationConnectorPluginexposesDiscoverAsyncbut noListRepositoriesAsync. Adding that method (and implementing it across the GitHubApp / GitLab / Gitea plugins) is tracked for a follow-up sprint.stella scan submit-list --file scan-list.txt --max-concurrent 20triggers a scan for each already-registered SBOM source id, throttled the same way.
Plugin Architecture
Each connector plugin implements IIntegrationConnectorPlugin (in StellaOps.Integrations.Contracts), which extends the shared IAvailabilityPlugin:
public interface IIntegrationConnectorPlugin : IAvailabilityPlugin
{
IntegrationType Type { get; }
IntegrationProvider Provider { get; }
Task<TestConnectionResult> TestConnectionAsync(IntegrationConfig config, CancellationToken ct = default);
Task<HealthCheckResult> CheckHealthAsync(IntegrationConfig config, CancellationToken ct = default);
}
(IAvailabilityPlugin supplies Name and an availability gate; there is no ProviderType string or ValidateConfigAsync method.) Discovery-capable connectors additionally implement IIntegrationDiscoveryPlugin (SupportedResourceTypes, DiscoverAsync). Connectors that gate operations declare IIntegrationConnectorCapabilityDeclaration.DeclaredCapabilities over the capability ids integration:test-connection, integration:health-check, integration:discovery, integration:discovery:<resourceType>, and integration:credential-materialization (IntegrationConnectorCapabilities).
IntegrationConfig (the value passed to connectors) carries IntegrationId, Type, Provider, Endpoint, ResolvedSecret (plaintext, resolved per-backend by IntegrationService.BuildConfigAsync), OrganizationId, ExtendedConfig, and SecretRefUri (set instead of ResolvedSecret for process-isolated connectors).
Image-resident connectors (the canonical set currently registered via IntegrationBuiltInPluginCatalog.GetAssemblies()):
- DockerRegistry / GitLabContainerRegistry — OCI Distribution connector. This is a signed/profile-mounted connector bundle, not an image-resident WebService dependency.
- Feed-mirror and object-storage probes registered from the WebService assembly itself; see
FeedMirrorConnectorPlugins/ObjectStorageConnectorPlugins.
Profile-bundled connectors:
- GitHubApp — GitHub App / GitHub Enterprise Server authentication, repository/code-scanning discovery.
- GitLab — GitLab Server / GitLab CI access-token authentication, project discovery.
- Gitea — Gitea SCM connector.
- Harbor — Harbor robot-account authentication, project/repository enumeration.
- Jenkins, Nexus, Vault, Consul, and the eBPF Agent remain optional/profile connector packages.
Profile-bundled connectors are not WebService project references. They must be supplied as signed directory bundles before the production provider list exposes them.
The current generated bundle profiles are:
recommended:stellaops.integrations.vault,stellaops.integrations.harbor,stellaops.integrations.githubapp, andstellaops.integrations.dockerregistry.harness:stellaops.integrations.inmemoryonly.integrations-scm-ci:stellaops.integrations.gitea,stellaops.integrations.jenkins,stellaops.integrations.nexus, andstellaops.integrations.gitlab.secrets-optional:stellaops.integrations.consul.runtime-host:stellaops.integrations.ebpfagent.
There is no base executable Integrations bundle in this slice. S3-compatible object storage and feed mirror providers stay host-owned until a future migration explicitly externalizes them.
InMemory is a deterministic test double for explicit test harnesses. It is NOT part of the built-in catalog and the default WebService runtime does not load it (GetSupportedProviders(includeTestOnly) filters it out unless explicitly requested).
External connector plugin admission
The production WebService does not treat a DLL in Integrations:PluginsDirectory as a valid connector by filename alone.
- Image-resident connectors are loaded from
IntegrationBuiltInPluginCatalogand are governed by normal Stella Ops build provenance. - Optional connector bundles use a directory-local manifest named
stellaops.integration-plugin.json. The manifest schema version is1and declaresid, SemVerversion, relativeassembly,providers[](type,provider,aliases[]),capabilities[], detachedsignature.path,trustRoots[], andprofiles[]. - Bundle manifests fail closed before runner metadata is trusted when required capability declarations are missing. Baseline connector bundles must declare
integration:test-connectionandintegration:health-check; discovery connectors must also declareintegration:discoveryor a resource-specificintegration:discovery:<resourceType>capability; connectors that need authref/brokered credential materialization can declareintegration:credential-materialization. Integrations:PluginProfileRootsadds profile-mounted bundle roots without changing the legacy base directory. Existing roots are loaded in deterministic path order, and each root’s final path segment must be present in every bundle manifest’sprofiles[]; a mismatch is rejected asbundle_profile_mismatch.- External directory plugins are disabled by default with
Integrations:PluginAdmission:EnableExternalDirectoryPlugins=false. - If candidate DLLs are present while external directory loading is disabled, startup fails explicitly instead of silently accepting or ignoring potentially untrusted code.
- Startup validation fails when external directory loading is enabled unless
Integrations:PluginAdmission:EnforceSignatureVerificationremains true and a local trust root is configured (PublicKeyPath,CertificatePath, orCertificateIdentityplusCertificateOidcIssuer). File-backed trust material must exist at startup. - In
Production, startup validation additionally requiresIntegrations:PluginAdmission:ProcessIsolationProvider=stellaops.pluginhost.process.v1,ProcessIsolationRunnerPath, and a successful runner handshake. The provider starts the configured child process and expects a deterministic JSON readiness payload before startup validation succeeds. - The process-v1 provider also defines a deterministic JSON operation IPC envelope with per-invocation timeout/kill behavior and a bounded stdout/stderr output policy (
ProcessIsolationMaxOutputBytes, default 65536 bytes per stream). Over-limit handshake or operation output kills the child runner and fails closed withsandbox_runner_output_limit_exceededorsandbox_operation_output_limit_exceededbefore IPC output is trusted. Dead runner paths such as missing runners, early exit before a response, malformed operation responses, timeout kills, and malformed runner-health state return deterministic lifecycle evidence (runner.lifecycleState, started/timed-out/output-limit flags, request id, and exit code when known) withpluginCodeExecuted=false. The host can also enforce configured supervised operation budgets (ProcessIsolationOperationCpuTimeBudgetMillisecondsandProcessIsolationOperationPrivateMemoryBudgetBytes): it samples the direct child runner process CPU time and private memory, kills on budget breach, and fails closed withsandbox_operation_cpu_budget_exceededorsandbox_operation_memory_budget_exceeded. This is not an OS/kernel hard limit and does not supervise a process tree. The shipped runner accepts runner-health and connector metadata operations, and can executeintegration:test-connection,integration:health-check, andintegration:discoveryoperations for a connector loaded inside the child process when the operation allowlist permits them. Responses includepluginCodeExecuted=trueand runner process evidence. - When process isolation is required, the loader verifies the candidate signature, asks the child runner to read connector metadata and capability declarations, and registers a process-backed proxy. The WebService process does not call
Assembly.Loadfor the external DLL. - The default connector operation allowlist enables
integration:test-connection,integration:health-check, andintegration:discovery. Authref-backed calls for those operations require a broker grant; other connector operations require explicit allowlist support and implementation. - Verification uses the shared plugin signature verifier. The default Cosign verifier expects a detached
.sigbeside each connector assembly and defaultsUseRekorTransparencyLog=falseso offline and air-gapped deployments do not call public transparency services. - Directory-loaded connectors must implement
IIntegrationConnectorCapabilityDeclarationand declare at leastintegration:test-connectionandintegration:health-check; discovery-capable connectors must also declareintegration:discovery. - The loader wraps declaring connectors in a capability-enforcing proxy before adding them to the registry. Test-connection, health, and discovery calls obtained from
IntegrationPluginLoaderfail closed when the required capability is absent, without resolving secrets or calling the plugin operation.IntegrationServicekeeps its own pre-invocation checks as a second guard. - Rejected plugins are not instantiated, cannot report successful
test-connectionresults, and are surfaced through loader failure metadata. - The current Integrations WebService provides a child-runner readiness boundary, process-backed metadata validation, process-backed
integration:test-connection, process-backedintegration:health-check, and process-backedintegration:discoveryexecution. - For process-backed connectors,
IntegrationServicedoes not resolve the catalog row’sauthRefUriwhile constructing the generic operation request. It passes only an opaque authref handle in the process-v1secretReferences[]field plus a broker grant when a broker is configured. The generic IPC payload, process args, process environment, and response evidence intentionally omit both the authref URI and resolved secret value. - Authref-backed directory-loaded connector operations now use a host-owned per-operation named-pipe secret broker. The WebService creates a one-use grant bound to request id, operation, opaque handle, and TTL (
ProcessIsolationSecretBrokerTtlMilliseconds, default 5000 ms). The child runner connects to that channel, presents the grant, and receives the resolved secret only after broker authorization; connector code executes only after materialization succeeds. Missing, expired, replayed, malformed, or unauthorized grants fail closed with deterministic broker error codes andpluginCodeExecuted=false. - The process-v1 host enforces per-invocation timeout/kill, stdout/stderr output ceilings, deterministic dead-runner/lifecycle evidence, configured supervised CPU/private-memory operation budgets, process discovery execution, and brokered single-authref materialization. Kernel-enforced CPU/memory limits, long-lived lifecycle supervision, restart policy, process-tree accounting, platform-specific named-pipe/Unix-socket peer identity ACLs, durable broker audit event routing, and multi-secret operations remain follow-up work.
This keeps test-only connectors such as InMemory out of production discovery and prevents unsigned external connector binaries from becoming production-valid integration evidence.
Provider endpoint contracts
- GitHub App - Operators provide either the GitHub Cloud root (
https://github.com), a GitHub Enterprise Server root, or an explicit/api/v3base. The connector normalizes the endpoint to a single API root and probes relativeapp/rate_limitpaths so GitHub Enterprise onboarding never falls back to origin-root/app. - Harbor - Operators provide the Harbor registry base URL. The current registry connector is descriptor-driven and uses the OCI Distribution v2 surface (
/v2/,_catalog, tag lists, manifest HEAD) for connection tests, discovery, and image enumeration. The deterministicharbor-fixture.stella-ops.localnginx fixture only serves Harbor API-shaped/api/v2.0responses; it is useful for retained API-onboarding checks but is not sufficient for the registry provider matrix because it does not expose/v2/. Thes020-harbor-registryfixture runs Harbor’s registry component and proves that OCI surface. Full Harbor core/portal/database/jobservice behavior is now covered by the S020 full local app proof indocs/qa/feature-checks/runs/2026-06-23-s020-harbor-full-local-app-proof/. - Docker Registry / GitLab Container Registry - Operators provide the registry base URL. When the registry responds with
WWW-Authenticate: Bearer ..., the connector exchanges the configured secret against the advertised token realm and retries with the returned bearer token. The local GitLab registry usesauthref://vault/gitlab#registry-basic, storingusername:personal-access-token.
Secret Authority
The Integrations service now exposes a provider-agnostic Secret Authority boundary for onboarding flows that need to stage credential material before creating an integration.
Goals
- Keep raw secrets out of the integration catalog.
- Let UI and CLI stage credentials once and receive
authref://...bindings in return. - Audit secret bundle creation/update separately from integration CRUD.
- Fail truthfully when a secrets-manager provider is visible but not writable in this release.
API surface (/api/v1/secret-authority)
GET /targets- Lists tenant-visible secrets-manager integrations that can be used as staging targets.
- Returns integration metadata only:
integrationId,name,provider,endpoint,status,logicalPathHint,supportsWrite.
PUT /bundles/{bundleId}- Stages or updates a credential bundle in the selected target.
- Request body:
targetIntegrationId, optionallogicalPath,entries[], optionallabels,overwrite. - Response returns only metadata plus generated authrefs:
bundleIdlogicalPathauthRefs- target provider and endpoint
Current writer support
- Vault KV v2 is the first shipped writer.
- Other secrets-manager providers remain visible in discovery, but write attempts fail explicitly with
501 not_implemented. - For Vault targets, the service writes directly to the target secret engine and returns authrefs such as:
authref://vault/gitlab/server#access-tokenauthref://vault/gitlab/registry#registry-basic
Security model
- Read policy:
integration:readviaIntegrationPolicies.SecretAuthorityRead - Write policy:
integration:writeviaIntegrationPolicies.SecretAuthorityWrite - Secret bundle writes emit the
upsert_secret_bundleaudit action in the Integrations audit module. - The API never echoes raw secret values back to the caller.
UI / CLI usage
- The Web Integrations Hub uses Secret Authority for inline GitLab Server, GitLab CI, and GitLab Container Registry onboarding.
- The CLI exposes the same flow through:
stella config integrations secrets targetsstella config integrations secrets upsert-bundle --bundle <id> --target <integration-id> --entry key=value
- Integration create/update operations persist either an
authRefUri(when the operator selects theauthrefbackend) or an AEAD-sealedinlineSecret(when the operator selects theinlinebackend). The two are mutex; see “Credential backends” above. Forauthrefrows the secret bundle and authref lifecycle are owned by the secret-authority target, not by the catalog row. Forinlinerows the catalog row carries the sealed ciphertext directly (seeStellaOps.Cryptography.CredentialStore.CredentialAead).
Security Considerations
Public error-disclosure boundary
Integrations classifies failure text by origin; authentication alone does not make arbitrary provider text safe to return:
| Origin | Classification | Public contract |
|---|---|---|
| ASP.NET request binding and JSON parsing | Untrusted submitted-data diagnostic | HTTP 400 returns stable host-authored detail plus traceId; raw parser/binding text is not projected. |
| Credential mutex, credential-kind validation, anonymous-registry policy, and Secret Authority 4xx validation | Client-safe validation | The reviewed validation detail and stable error code are returned to the authenticated caller. |
| Secret Authority provider/credential/infrastructure 5xx failures | Sensitive server failure | The API returns the stable errorCode plus a generic detail; Vault response bodies, authrefs, target names, and exception text remain server-side. |
| In-process connector exceptions and discovery failures | Sensitive provider failure | Results use stable codes such as connector_transport_failure and integration_discovery_failed; exception graphs are scrubbed where credential material may be reachable and logs record only exception type plus bounded operation identifiers. |
| External bundle admission, signature verification, availability probes, child-runner stderr, host paths, and process response failures | Operator-only diagnostic | Plugin probe/results expose stable admission or sandbox codes. Raw exception messages, runner stderr, assembly paths, and arbitrary child response details are not projected. |
Successful authenticated connector probes may retain bounded operational facts: HTTP status codes; normalized operation.success, operation.healthStatus, and operation.durationMs; typed runner.* lifecycle counters/states; boolean broker/materialization evidence; secret-reference counts/kinds; and explicitly redacted secret-leak proof fields. Arbitrary plugin detail keys and plugin-authored failure messages are not part of the public contract. Correlation uses the HTTP trace identifier and stable error code, not raw exception text.
The WebService enforces this boundary after every in-process or process-isolated test-connection and health result. Connector-authored messages, endpoint/path details, remote payload fragments, and write-probe prose are replaced or dropped before response, event, audit, or persisted probe-state projection; plugins cannot opt out of the host sanitizer. Top-level health status is restricted to defined HealthStatus values (unknown values become Unknown), and last_health_check_at is stamped from the host clock so a connector cannot poison monitor scheduling with a future timestamp.
- Explicit credential backend: Every integration row carries an explicit
credentialBackend(inline|authref|none). The legacynone + non-null auth_ref_uriback-compat shape was dropped in Sprint 032; registration rejects it with HTTP 400 and the DB CHECK constraint enforces canonical shape on every write. - AuthRef URI credential model (
authrefbackend): Credentials are stored in an external vault (e.g., HashiCorp Vault, Azure Key Vault). The integration catalog stores only the URI reference (authref://vault/path/to/secret), never the raw secret. - Inline credential model (
inlinebackend): Credentials are stored Stella-managed in the catalog row, AEAD-sealed viaStellaOps.Cryptography.CredentialStore.CredentialAeadwith per-row(kek_id, kek_version, algorithm)triple. KEK lifecycle is governed bycrypto.kek_versions(Sprint 028). The trusted in-clusterresolve-credentialsendpoint requiresintegration:operateand returns only a response-domain AEAD envelope; it never emits, persists, or logs resolved plaintext. Canonical operator workflow:devops/agents-targets/scripts/customer-onboarding/01b-register-inline-integrations.sh(Sprint 029 FIX-003). - Tenant scoping: All database queries include a mandatory tenant filter enforced at the
DbContextlevel via query filters. - Audit emission: Create, update, delete, test, discovery, AI Code Guard, and secret-authority operations emit shared audit events and structured logs with actor/resource context. The Integrations module does not currently own a durable audit-log table.
- Plugin admission: Image-resident connectors run from the WebService catalog. Directory-loaded connector DLLs require startup-valid signature enforcement and local trust material, and production external directory admission requires a healthy
stellaops.pluginhost.process.v1runner handshake plus child-runner metadata validation. Declaring external connectors are exposed through a process-backed capability proxy. The default connector operations that can execute across the child-runner boundary areintegration:test-connection,integration:health-check, andintegration:discovery; authref-backed process operations receive only opaque handles and a one-use broker grant in generic IPC, then materialize plaintext through the broker inside the child runner after authorization. The process-v1 host enforces per-invocation timeout/kill, bounded stdout/stderr capture, deterministic dead-runner evidence, configured sampled CPU/private-memory budget kills, and response-detail redaction for materialized secret values, while broader execution still waits on long-lived lifecycle supervision, restart policy, process-tree/kernel resource controls, platform-specific broker channel ACLs, and durable broker audit routing.
Observability
- Metrics: The Integrations WebService does not currently register a dedicated
Meter/counters of its own. (Earlier drafts listedintegration_health_status,integration_health_latency_ms, andintegration_crud_total— those metric instruments do not exist in the source; treat them as a target, not shipped behavior.) Request-level telemetry comes from the shared host/router instrumentation, not from module-local instruments. - Audit: CRUD, test, discover, AI Code Guard, resolve-credentials, and secret-bundle operations emit shared unified-audit events (
AuditModules.Integrations, actionsCreate/Update/Delete/Test/Discover/RunCodeGuard/ResolveCredentials, plusupsert_secret_bundle) viaIAuditEventEmitter, in addition to aIIntegrationAuditLoggerstructured-log trail. The resolve-credentials audit records backend + kind metadata only, never the secret value. - Logs: Structured logs include
integrationId,tenantId/tenant scope,Provider, and the action verb. - Lifecycle events:
IntegrationServicepublishes in-process lifecycle events throughIIntegrationEventPublisher(created/updated/deleted/status-changed/health-changed/test-connection/RoI-metadata-changed); the default publisher is a logging implementation (LoggingEventPublisher).
Performance Characteristics
- Health checks run on demand through
GET /api/v1/integrations/{id}/healthand on the default-on background sweep (IntegrationHealthMonitor, everyIntegrations:HealthMonitor:Interval, default 5 min, first sweep after a 1 minInitialDelay). The sweep is best-effort — a per-integration failure is caught and never aborts the pass — and cost scales with the number of active integrations across all tenants. SetIntegrations:HealthMonitor:Enabled=falseto disable periodic outbound probing. - Registry-image security enrichment retains its fixed per-digest Scanner lookup concurrency inside one request, while a process-wide outer gate admits one enrichment request at a time. This prevents concurrent registry pages from multiplying the inner database fan-out across requests; pagination and the nullable/unevaluated response contract are unchanged.
- Plugin loading happens once at startup (
IntegrationPluginLoaderloads the image-resident catalog and, if enabled and admitted, external directory candidates) and the resulting provider registry is held in memory for the process lifetime. There is no runtime re-discovery endpoint. - Integration queries are backed by the indexes on
tenant_id,type,provider, andstatus(see Database Schema) for tenant-scoped filtering.
IDE Extensions (VS Code, JetBrains)
The Integrations module also owns the IDE extension plugins, located under src/Integrations/__Extensions/. These are non-.NET projects intended to provide developer-facing tooling consuming the Orchestrator/Router APIs.
Status: these extensions are early skeletons, not yet wired to live backend APIs. As shipped, the VS Code extension is a single
src/extension.tsproviding tree-view scaffolding and a configurable backend URL (defaulthttps://localhost:5001); it does not yet issue the/api/v1/releases/*,/api/v1/promotions/*, or/oauth/tokencalls described in “Data Flow (Extensions)” below — that flow is the target design. The JetBrains plugin shipsStellaOpsPlugin.ktbut does not include a committed Gradle wrapper. Treat the feature lists and data flow below as planned scope.
VS Code Extension (__Extensions/vscode-stella-ops/)
- Technology: TypeScript, VS Code Extension API
- Build:
npm run compile(TypeScript compilation) - Features: Tree views for releases and environments, CodeLens annotations for
stella.yaml, command palette integration, status bar widget - Manifest:
package.json(extension manifest, commands, views, configuration)
JetBrains Plugin (__Extensions/jetbrains-stella-ops/)
- Technology: Kotlin, IntelliJ Platform SDK
- Build: Gradle (
./gradlew build) - Features: Tool windows (Releases/Environments/Deployments tabs), YAML annotator, action menus, status bar widget
- Entry point:
StellaOpsPlugin.kt
Design Principles (Extensions)
- Thin client - Extensions contain no business logic; all state and decisions live in backend services
- Consistent experience - Both plugins expose equivalent functionality despite different technology stacks
- Non-blocking - All API calls are asynchronous; the IDE remains responsive during network operations
- Offline-tolerant - Graceful degradation when the Stella Ops backend is unreachable
Data Flow (Extensions)
[Developer IDE] --> [Extension/Plugin]
|
+-- GET /api/v1/releases/* --------> [Orchestrator API]
+-- GET /api/v1/environments/* ----> [Orchestrator API]
+-- POST /api/v1/promotions/* -----> [Orchestrator API]
+-- POST /oauth/token -------------> [Authority]
Authentication uses OAuth tokens obtained from the Authority service, stored in the IDE’s secure credential store (VS Code SecretStorage, JetBrains PasswordSafe).
