Documentation
¶
Overview ¶
RDS IAM database authentication for the Postgres backend. When DISCO_PG_IAM_AUTH is set, the scanner authenticates to Postgres with a short-lived IAM token instead of a password, so the scanner task needs no DB password provisioned. Tokens (~15 min TTL) gate only the handshake, so a fresh one is minted before every physical connection via the pgx BeforeConnect hook.
Postgres backend for *Store: single struct, driver-branched dialect bits; the SQLite path is untouched. The backend is single-tenant — a plain pool against one schema. Module consumers needing multi-tenancy (disco-saas) inject a per-connection hook via WithAfterConnect (below) to pin search_path and set the app.* GUCs their row-level-security layer needs.
Package store is the SQLite persistence layer (modernc.org/sqlite, CGO-free) for resources, relationships, hierarchy closure, and scan lifecycle. See store/CLAUDE.md for table shape and edge kinds.
Index ¶
- Constants
- Variables
- func ResourceID(provider, accountID, nativeID string) string
- func TargetSchemaVersion() (int, error)
- func ToRFC3339(s string) string
- type CheckRun
- type Checkpoint
- type Driver
- type FindingFilter
- type GraphAllOpts
- type GraphEdge
- type GraphNode
- type GraphPathOpts
- type GraphResult
- type GraphWalkOpts
- type PGOption
- type RelEdge
- type Relationship
- type Resource
- type ResourceFilter
- type ResourceVersion
- type Scan
- type ScanDiff
- type ScanError
- type ScanErrorEntry
- type ScanWarning
- type ServiceStatus
- type Store
- func (s *Store) AppendScanError(id string, e ScanErrorEntry) error
- func (s *Store) ArchiveResource(rootID, deletedBy string) (bool, error)
- func (s *Store) BeginRelBuffer() *Store
- func (s *Store) Close() error
- func (s *Store) CompleteScan(id string, resourceCount int) error
- func (s *Store) CountManaged() (int, error)
- func (s *Store) CountResourcesByScan(scanID string) (int, error)
- func (s *Store) CreateScan(providers []string, scope map[string]any) (string, error)
- func (s *Store) CreateScanWithID(id string, providers []string, scope map[string]any) (string, error)
- func (s *Store) CurrentSchemaVersion() (int, error)
- func (s *Store) DB() *sqlx.DB
- func (s *Store) DeleteCheckRun(id string) error
- func (s *Store) DeleteScanCheckpoints(scanID string) error
- func (s *Store) DescendantsOf(parentID string, f ResourceFilter) ([]Resource, error)
- func (s *Store) DiffScans(fromScanID, toScanID string) (*ScanDiff, error)
- func (s *Store) FailScan(id string, scanErr string) error
- func (s *Store) FlushRelBuffer() error
- func (s *Store) GetCheckRun(id string) (*CheckRun, error)
- func (s *Store) GetCheckpoint(scanID, provider, service, scope string) (lastToken string, ok bool, err error)
- func (s *Store) GetResource(id string) (*Resource, error)
- func (s *Store) GetResourceVersions(rootID string) ([]ResourceVersion, error)
- func (s *Store) GetScan(id string) (*Scan, error)
- func (s *Store) GraphAll(opts GraphAllOpts) (*GraphResult, error)
- func (s *Store) GraphPath(fromID, toID string, opts GraphPathOpts) (*GraphResult, error)
- func (s *Store) GraphWalk(seedID string, opts GraphWalkOpts) (*GraphResult, error)
- func (s *Store) InsertResourcesIfAbsent(resources []*Resource) (inserted int, err error)
- func (s *Store) LatestCompleteScan(provider string) (*Scan, error)
- func (s *Store) LatestIncompleteScan() (*Scan, error)
- func (s *Store) ListCheckRuns() ([]CheckRun, error)
- func (s *Store) ListCheckpoints(scanID string) ([]Checkpoint, error)
- func (s *Store) ListFindings(f FindingFilter) ([]StoredFinding, error)
- func (s *Store) ListRelationships() ([]Relationship, error)
- func (s *Store) ListResources(f ResourceFilter) ([]Resource, error)
- func (s *Store) ListScans() ([]Scan, error)
- func (s *Store) PartialScan(id string, resourceCount int, scanErr string) error
- func (s *Store) PersistCheckRun(rulesPaths, packs []string, severityFilter string, resourceCount int, ...) (string, error)
- func (s *Store) RecordHierarchy(childID, parentID string) error
- func (s *Store) RecordHierarchyBatch(pairs [][2]string) error
- func (s *Store) RelationshipsFrom(fromID string, kinds ...string) ([]Relationship, error)
- func (s *Store) RelationshipsTo(toID string, kinds ...string) ([]Relationship, error)
- func (s *Store) ReportError(e ScanError)
- func (s *Store) ReportResolveComplete(provider string, edges int)
- func (s *Store) ReportResolveStart(provider string)
- func (s *Store) ReportService(service, scope string, total, newCount, changed, errCount int, ...)
- func (s *Store) ReportWarning(w ScanWarning)
- func (s *Store) ResolveResource(arg, provider, rtype, account string) (*Resource, error)
- func (s *Store) RestoreResource(rootID string) (bool, error)
- func (s *Store) ReversedContainsEdges() ([]Relationship, error)
- func (s *Store) SaveCheckpoint(scanID, provider, service, scope, lastToken string) error
- func (s *Store) UpsertRelationship(fromID, toID, kind, direction string, attrs *string) error
- func (s *Store) UpsertRelationships(edges []RelEdge) error
- func (s *Store) UpsertResource(r *Resource) (int, error)
- func (s *Store) UpsertResources(resources []*Resource) (inserted int, err error)
- func (s *Store) WithRelCounter(c *atomic.Int64) *Store
- func (s *Store) WithUpsertCounters(newC, changedC *atomic.Int64) *Store
- type StoredFinding
Constants ¶
const ( DirOut = "out" DirIn = "in" DirBoth = "both" )
Direction constants for GraphWalkOpts.
const ( RelContains = "contains" RelAttachedTo = "attached-to" RelUses = "uses" RelRoutesTo = "routes-to" RelPeer = "peer" RelAssumes = "assumes" RelCrossAccountTrust = "cross-account-trust" // AWS IAM role trust → foreign account/role/user RelCrossSubRBAC = "cross-sub-rbac" // Azure RBAC assignment → resource in different sub RelCrossProjectIAM = "cross-project-iam" // GCP IAM binding → SA in different project RelOrgIAM = "org-iam" // GCP org/folder IAM binding → SA in some project, org-wide blast radius RelBoundedBy = "bounded-by" // IAM principal → permission-boundary policy (AWS) or analogue )
Relationship kind constants.
Variables ¶
var ErrNoPath = errors.New("no path between resources")
ErrNoPath signals GraphPath found no route to the destination within the configured depth/filter constraints. cmd layer maps this to a nonzero exit code so shell pipelines can branch on reachability.
Functions ¶
func ResourceID ¶
ResourceID computes a stable deterministic ID for a resource. type is deliberately excluded — a resource's identity is (provider, account, native_id); its type is a versioned attribute (a type change supersedes, it does not fork).
func TargetSchemaVersion ¶
TargetSchemaVersion returns the highest migration version embedded in the binary — the schema state a fully-migrated DB will land at after Open() applies any pending migrations. Used by read-only callers (cmd/helpers.go's openDB) to detect a stale on-disk schema and reject with a clear hint.
Types ¶
type CheckRun ¶
type CheckRun struct {
ID string `db:"id"`
StartedAt string `db:"started_at"`
FinishedAt *string `db:"finished_at"`
RulesPathsJSON string `db:"rules_paths"`
RulesPaths []string `db:"-"`
PacksJSON string `db:"packs"`
Packs []string `db:"-"`
SeverityFilter *string `db:"severity_filter"`
ResourceCount *int `db:"resource_count"`
FindingCount *int `db:"finding_count"`
// WorkspaceID is the per-workspace RLS discriminator. Omitted from the read
// projection (checkRunColumns) like Resource.WorkspaceID — the single-tenant
// store never reads it and the disco-saas RLS layer filters by workspace — so it stays
// nil. Kept as a field so the type still documents the column.
WorkspaceID *string `db:"workspace_id"`
}
CheckRun records one invocation of `disco check --persist`. ID shape mirrors scans.id (32-hex-char crypto/rand). Timestamps are RFC3339 UTC.
type Checkpoint ¶
type Checkpoint struct {
ScanID string
Provider string
Service string
Scope string
LastToken string
UpdatedAt time.Time
}
Checkpoint is a per-(scan, provider, service, scope) progress marker. Scanners save one after each successfully-upserted page; on resume, `LastToken` feeds back to the upstream SDK as a continuation cursor. The token is opaque to the store — this schema is the foundation for a future incremental-scan feature (see ROADMAP.md).
type FindingFilter ¶
type FindingFilter struct {
CheckRunID string
FindingID string
Category string
Providers []string
Type string
ResourceID string
Since string
Limit uint64
Offset uint64
}
FindingFilter shapes ListFindings queries. Empty fields skip the clause. Since is RFC3339; the SQL JOIN compares against check_runs.started_at.
type GraphAllOpts ¶
type GraphAllOpts struct {
// IncludeManaged: when false (default), provider-managed resources only
// appear if they have at least one edge to a customer-managed resource;
// orphan managed resources (e.g. unused AWS-managed policies, built-in
// Azure role definitions) drop out. When true, every resource is
// included regardless of edges.
IncludeManaged bool
ExcludeTypes []string
ExcludeRegions []string
MaxNodes int
MaxEdges int
}
GraphAllOpts configures GraphAll. No traversal knobs (depth/kinds/direction) since GraphAll is store-dump style — every relationship in the DB is a candidate edge. Filter knobs mirror GraphWalk so the cmd layer reuses flag plumbing.
type GraphEdge ¶
type GraphEdge struct {
FromID string `json:"fromId"`
ToID string `json:"toId"`
Kind string `json:"kind"`
}
GraphEdge is a directed relationship between two resource IDs.
type GraphPathOpts ¶
type GraphPathOpts struct {
MaxDepth int // 0 = unlimited (capped internally to 64 to bound runtime)
Kinds []string // empty = all
Direction string // out/in/both, default both
IncludeManaged bool
ExcludeTypes []string
ExcludeRegions []string
}
GraphPathOpts configures GraphPath. Direction defaults to "both" (treats the graph as undirected for reachability) since "can A reach B" usually wants the answer regardless of edge orientation.
type GraphResult ¶
type GraphResult struct {
SeedID string `json:"seedId"`
Nodes []GraphNode `json:"nodes"`
Edges []GraphEdge `json:"edges"`
TruncatedNodes int `json:"truncatedNodes,omitempty"`
TruncatedEdges int `json:"truncatedEdges,omitempty"`
ExcludedTypes int `json:"excludedTypes,omitempty"` // dropped by ExcludeTypes
ExcludedRegions int `json:"excludedRegions,omitempty"` // dropped by ExcludeRegions
}
GraphResult is GraphWalk's output: seed ID plus ordered nodes and edges. Truncated{Nodes,Edges} count candidates dropped by the caller's MaxNodes/MaxEdges caps; zero when no cap or no truncation.
type GraphWalkOpts ¶
type GraphWalkOpts struct {
MaxDepth int // inclusive; 0 = seed only
Kinds []string // empty = all relationship kinds
Direction string // "out", "in", "both" (default: "both")
// IncludeManaged=false treats provider-managed resources as terminal: they
// appear as edge endpoints when reached from a non-managed node, but BFS
// does not expand through them. The seed itself is never filtered.
IncludeManaged bool
// ExcludeTypes drops nodes whose Type matches any pattern. Patterns are
// either literal ("aws:iam:role") or suffix-glob ("aws:iam:*"). The seed
// is never excluded by this filter.
ExcludeTypes []string
// ExcludeRegions drops nodes whose Region matches any entry exactly.
// Resources without a region (global services) are never matched.
// The seed is never excluded by this filter.
ExcludeRegions []string
// MaxNodes / MaxEdges cap the result size. BFS halts adding new nodes /
// edges once the cap is hit; the cumulative drop count is returned in
// GraphResult.Truncated{Nodes,Edges}. 0 means unlimited.
MaxNodes int
MaxEdges int
}
GraphWalkOpts configures GraphWalk traversal.
type PGOption ¶ added in v0.9.0
type PGOption func(*pgConfig)
PGOption configures an OpenPostgres call.
func WithAfterConnect ¶ added in v0.9.0
WithAfterConnect registers a hook that runs once per physical connection, before any handle reaches database/sql. It is the multi-tenant extension point: disco-saas passes a hook that SETs search_path to a per-tenant schema and set_config's app.tenant_id / app.workspace_id for its RLS policies. The CLI passes no options and gets a plain single-tenant pool.
Composes with RDS IAM auth (pgx's separate BeforeConnect phase) — both can be active at once.
type RelEdge ¶ added in v0.10.0
RelEdge is one relationship to upsert in a batch via UpsertRelationships. Empty Direction defaults to "directed". Mirrors UpsertRelationship's args.
type Relationship ¶
type Relationship struct {
ID int64 `db:"id"`
FromID string `db:"from_id"`
ToID string `db:"to_id"`
Kind string `db:"kind"`
Direction string `db:"direction"`
Attributes *string `db:"attributes"` // JSON
DiscoveredAt string `db:"discovered_at"`
// WorkspaceID is the per-workspace RLS discriminator. Omitted from the read
// projection (relationshipColumns), like Resource.WorkspaceID — the
// single-tenant store never reads it and disco-saas's RLS layer filters by
// workspace — so it stays nil. (No TenantID field: the single-tenant schema
// has no tenant_id column; the disco-saas overlay column simply isn't selected.)
WorkspaceID *string `db:"workspace_id" json:"-"`
}
Relationship represents a directed edge between two resources.
type Resource ¶
type Resource struct {
ID string `db:"id" json:"id"`
Provider string `db:"provider" json:"provider"`
AccountID string `db:"account_id" json:"accountId"`
AccountName *string `db:"account_name" json:"accountName"`
Type string `db:"type" json:"type"`
NativeID string `db:"native_id" json:"nativeId"`
Name *string `db:"name" json:"name"`
Region *string `db:"region" json:"region"`
Zone *string `db:"zone" json:"zone"`
Status *string `db:"status" json:"status"`
TagsJSON *string `db:"tags" json:"-"` // surfaced as `tags` via MarshalJSON
AttributesJSON string `db:"attributes" json:"-"` // surfaced as `attributes` via MarshalJSON
CreatedAt *string `db:"created_at" json:"createdAt"`
DiscoveredAt string `db:"discovered_at" json:"discoveredAt"`
DiscoveredBy string `db:"discovered_by" json:"discoveredBy"`
ManagedByProvider bool `db:"managed_by_provider" json:"managedByProvider"`
WorkspaceID *string `db:"workspace_id" json:"-"` // per-workspace RLS discriminator; nil when the disco-saas app.workspace_id GUC was unset
}
Resource represents a discovered cloud resource.
Wire shape ≠ storage shape: AttributesJSON / TagsJSON live as JSON strings in the DB but MarshalJSON / UnmarshalJSON surface them as nested `attributes` / `tags` objects on the wire, with camelCase keys matching policy.Finding and coverage.Row.
Contract: every key documented under `disco check --help` is always present on the wire (never dropped by `,omitempty`). Optional pointer fields render as `null` when unset; `tags` always emits as an object (possibly empty); `managed_by_provider` always emits its bool. Drift here is the F6 paper-cut from focus-group/SUMMARY.md — fix at this struct, not in each command's renderer.
func (Resource) MarshalJSON ¶
MarshalJSON emits Resource with camelCase keys and nested `attributes` / `tags` rather than stringified JSON blobs. Empty / missing / malformed blobs render as `{}` so consumers can always traverse `input.attributes.X` without a presence check.
func (*Resource) UnmarshalAttributes ¶
UnmarshalAttributes unmarshals the resource's JSON attributes into v.
func (*Resource) UnmarshalJSON ¶
UnmarshalJSON reverses MarshalJSON: nested `attributes` / `tags` objects fold back into AttributesJSON / TagsJSON strings so []Resource round-trips byte-stably through encode → decode.
type ResourceFilter ¶
type ResourceFilter struct {
Providers []string
AccountID string
Types []string
ExcludeTypes []string
Regions []string
Status string
// DiscoveredBy, when set, restricts results to rows inserted by the named
// scan id (matches `discovered_by`). The verified_by axis is selected
// separately via ScanAs (see the --scan-as flag).
DiscoveredBy string
// DiscoveredSince filters rows whose discovered_at >= this RFC3339
// timestamp. Stored timestamps sort lexicographically same as
// chronologically, so plain string comparison works. Pairs with
// DiscoveredBefore for half-open `[since, before)` queries.
DiscoveredSince string
// DiscoveredBefore filters rows whose discovered_at < this RFC3339
// timestamp (strict). Used for "stale" hygiene queries and as the upper
// half of a half-open interval with DiscoveredSince.
DiscoveredBefore string
// CreatedBefore filters rows whose created_at < this RFC3339 timestamp.
// Anchored on the resource's intrinsic CreateDate (lifted from the SDK at
// scan time), NOT discovered_at. Rows with NULL created_at are excluded;
// not every scanner lifts the SDK timestamp yet (see EBS volume precedent
// in commit 8e61c52).
CreatedBefore string
// CreatedSince filters rows whose created_at >= this RFC3339 timestamp.
// Pairs with CreatedBefore for half-open interval queries on intrinsic
// age. Same NULL caveat as CreatedBefore.
CreatedSince string
TagKey string
TagValue string
Limit uint64
Offset uint64
// ID, when set, restricts the result to a single row by primary key.
// Mirrors a `WHERE id = ?` short-circuit.
ID string
// IncludeManaged when false hides provider-managed resources (built-in
// roles, AWS-owned prefix lists, etc.). Defaults false at the SQL layer.
IncludeManaged bool
// SkipGlobals, when true, excludes rows whose region = "global". Used by
// `disco resources --exclude-global-region` and friends to opt out of the
// default "include globals when filtering by --regions" behaviour.
SkipGlobals bool
}
ResourceFilter defines optional filters for ListResources.
type ResourceVersion ¶
type ResourceVersion struct {
Resource
VerifiedAt *string `db:"verified_at" json:"verifiedAt"`
VerifiedBy *string `db:"verified_by" json:"verifiedBy"`
RootID string `db:"root_id" json:"rootId"`
PreviousVersionID *string `db:"previous_version_id" json:"previousVersionId"`
SupersededBy *string `db:"superseded_by" json:"supersededBy"`
VersionRowID string `db:"version_row_id" json:"versionRowId"`
DeletedAt *string `db:"deleted_at" json:"deletedAt"`
DeletedBy *string `db:"deleted_by" json:"deletedBy"`
}
ResourceVersion is the wire shape that carries verification and version-chain metadata. Embeds Resource, so new Resource fields cascade here automatically — no parallel edit needed.
Identity model:
- VersionRowID is the per-row UUIDv7 PK in the resources table.
- RootID is the deterministic ResourceID hash shared across every row in this resource's version chain. Resource.ID (embedded) also carries this hash — the resourceSelectColumns hook aliases `root_id AS id` on read so Resource projections stay consistent.
- PreviousVersionID points at the immediate predecessor in the chain (NULL on the root row).
- SupersededBy points at the successor that replaced this version (NULL on the current row of every chain).
- DeletedAt / DeletedBy carry the archival tombstone (see Store.ArchiveResource); NULL on a live row. A tombstone is the current row (SupersededBy NULL) with DeletedAt set.
type Scan ¶
type Scan struct {
ID string `db:"id"`
StartedAt string `db:"started_at"`
FinishedAt *string `db:"finished_at"`
Status string `db:"status"`
Providers []string `db:"-"` // stored as JSON
ProvidersJSON string `db:"providers"`
ScopeJSON string `db:"scope"`
Error *string `db:"error"`
// ErrorsJSON is the structured per-service failure array, JSON-encoded.
// SQLite stores it as TEXT, PG as JSONB; both round-trip through this
// string field. Default '[]' so SELECT * never NULL-scans.
ErrorsJSON *string `db:"errors"`
ResourceCount *int `db:"resource_count"`
MetaJSON *string `db:"meta"`
// WorkspaceID is the per-workspace RLS discriminator. Omitted from the read
// projection (scanColumns) like Resource.WorkspaceID — the single-tenant store
// never reads it and disco-saas's RLS layer filters per-workspace, so it stays nil.
// Kept as a field so the type still documents the column.
WorkspaceID *string `db:"workspace_id" json:"-"`
}
Scan represents a single discovery run.
func (Scan) MarshalJSON ¶
MarshalJSON renders a Scan with camelCase keys, parsed providers / scope / meta objects, and RFC3339 timestamps. The SQLite `datetime('now')` shape (`YYYY-MM-DD HH:MM:SS`) is normalised so consumers can use a single `time.Parse(time.RFC3339, ...)` regardless of row source.
type ScanDiff ¶
type ScanDiff struct {
FromScanID string `json:"fromScanId"` // "A"
ToScanID string `json:"toScanId"` // "B"
Added []Resource `json:"added"` // first seen in scan B
Stale []Resource `json:"stale"` // last seen in scan A, not refreshed by B
}
ScanDiff summarizes the resource delta between two scan runs A and B, assuming B ran after A over the same scope.
Limitations: the current schema stores only the *latest* state of each resource, not per-scan snapshots. This means:
- Added is precise: resources whose discovered_by == B.ID were first observed in scan B.
- Stale is approximate: resources whose verified_by == A.ID have not been re-verified by B (or later). They may be deleted in the cloud, or simply outside B's scope.
- Updated (attribute drift) cannot be computed without historical snapshots — not implemented.
type ScanError ¶
type ScanError struct {
Provider string // "aws", "azure", "gcp"
Service string // e.g. "ec2", "iam", "resolve:resolveBackupVaults"
Scope string // accountID[/region] or subscriptionID or projectID
Message string // err.Error()
}
ScanError is a per-service / per-resolver failure captured during a scan. Errors never abort the scan; they are accumulated and rendered as a grouped block at the end so the user sees each failure exactly once.
type ScanErrorEntry ¶
type ScanErrorEntry struct {
Service string `json:"service"`
Region string `json:"region"`
Code string `json:"code"`
Message string `json:"message"`
}
ScanErrorEntry is one structured failure row appended to scans.errors. service / region narrow the failure scope for UI grouping/filtering; code mirrors the AWS API error code (or a synthesised "transient" / "auth" / "throttle" for non-AWS providers); message is human-readable but terse.
type ScanWarning ¶
type ScanWarning struct {
Provider string // "aws", "azure", "gcp"
Service string // e.g. "kms:ListKeys", "compute"
Scope string // accountID[/region] or subscriptionID or projectID
Message string // err.Error()
}
ScanWarning is a non-fatal skip captured during a scan — typically an access-denied error on a single service or region. The scan orchestrator collects warnings in memory and renders them as a grouped block after the scan, instead of interleaving with progress output.
type ServiceStatus ¶ added in v0.14.0
type ServiceStatus uint8
ServiceStatus is the terminal state of a per-service scan, surfaced as a uniform "(<scope>: <state>)" suffix on the progress line (see cmd/scan.go::serviceStatusSuffix). ServiceDisabled: account/subscription/ project hasn't enabled the service but could → "(<tenant>: disabled)". ServiceUnavailable: service not deployed in this AWS region, nothing the user can do → "(region: unavailable)". ServiceNotEntitled: service exists but the tenant can't self-enable it — a support-tier gate (Trusted Advisor API needs Business/Enterprise AWS Support), a service closed to new customers (Migration Hub), or an account AWS hasn't made eligible (CloudSearch) → "(<tenant>: not entitled)". Distinct from ServiceDisabled because there's no toggle the user controls. ServiceBillingDisabled: the project/account has billing disabled (self-enableable — associate a billing account) → "(<tenant>: billing disabled)". Sits beside ServiceDisabled (both self-enableable preconditions) rather than the warnings block.
const ( ServiceOK ServiceStatus = iota ServiceDisabled ServiceNotEntitled ServiceBillingDisabled )
type Store ¶
type Store struct {
OnServiceComplete func(service, scope string, total, newCount, changed, errCount int, status ServiceStatus) // after each service scan; scope = AWS region (or "global"), Azure subscription ID, GCP project ID; errCount>0 surfaces "(with errors)", status surfaces "(<tenant>: disabled)" / "(region: unavailable)" / "(<tenant>: not entitled)" / "(<tenant>: billing disabled)"
OnResolveStart func(provider string) // just before phase-2 resolvers run
OnResolveComplete func(provider string, edges int) // after all resolvers finish
OnWarn func(ScanWarning) // skip-worthy error handled (transient, access-denied)
OnError func(ScanError) // service or resolver failure; never aborts the scan
// contains filtered or unexported fields
}
func Open ¶
Open opens (or creates) the SQLite database at path and applies any pending migrations. It creates the parent directory if it does not exist.
func OpenPostgres ¶
OpenPostgres opens a Postgres-backed Store at dsn. With no options it's the single-tenant PG backend the CLI uses; pass WithAfterConnect to layer multi-tenancy onto every connection.
The same *Store satisfies every read/write method the SQLite path supports; dialect differences live in dialect.go, rebind helpers, and the migration runner.
func OpenReadOnly ¶
OpenReadOnly opens the SQLite database at path with SQLITE_OPEN_READONLY. Skips migrate (schema is whatever's on disk) and skips chmod (caller owns the file). Any write path will fail at SQLite layer with "attempt to write a readonly database" — structural enforcement of the auditor / pipeline-handoff contract.
func WrapTx ¶
WrapTx returns a *Store that runs queries against tx instead of a connection pool. The returned store does NOT own the transaction — caller must Commit or Rollback. Close() is a no-op.
Intended for read-only use from a multi-tenant request path, where the caller has already issued `SET LOCAL search_path = tenant_<hex>, public` and `SET LOCAL app.tenant_id = '<uuid>'` on the tx. Write methods that call s.db.Begin* directly (UpsertResources, UpsertRelationships, RecordHierarchyBatch, etc.) will panic on a nil pool — intentional; do not invoke them on this code path.
drv must match the dialect of the tx's driver: store.DriverPostgres for a pgx-backed tx, store.DriverSQLite for SQLite.
func (*Store) AppendScanError ¶
func (s *Store) AppendScanError(id string, e ScanErrorEntry) error
AppendScanError appends one structured entry to scans.errors. PG path uses jsonb_insert; the SQLite fallback JSON-encodes the array once and rewrites. Both are concurrency-safe under the existing per-scan write pattern (one scanner mutates one scan).
func (*Store) ArchiveResource ¶ added in v0.15.0
ArchiveResource marks the current version of the resource chain identified by rootID as an archival tombstone, and reports whether a row was archived.
It appends a new current version row that carries deleted_at (now) and deleted_by, copying the prior row's payload and superseding it (previous_version_id -> old row, old row superseded_by -> the tombstone), so the pre-archive state stays in the version chain and Store.GetResourceVersions still returns it. verified_at / verified_by are carried forward unchanged: they record the last scan that actually saw the resource, which archiving does not alter.
deletedBy attributes the tombstone — a caller identifier (user id) for a manual archive, or a scan id for an automated coverage reaper.
The archive is soft and reversible. A later scan that re-sees the resource resurrects it automatically (UpsertResources clears deleted_at on its verify-only path and starts a fresh non-deleted row on a version split); Store.RestoreResource lifts the tombstone directly.
It reports false with a nil error when rootID has no live current row — the chain does not exist, or its current row is already a tombstone. The operation is therefore idempotent: a second call archives nothing.
func (*Store) BeginRelBuffer ¶ added in v0.10.0
BeginRelBuffer returns a shallow copy of the Store whose UpsertRelationship calls accumulate into an in-memory buffer instead of each running its own autocommit transaction. Call FlushRelBuffer on the returned store to write every buffered edge in one transaction via UpsertRelationships.
Phase-2 resolvers emit ~1k+ edges per scan; on SQLite (MaxOpenConns=1) every autocommit INSERT serialises on the single writer, each paying prepare + WAL-frame + lock overhead. Buffering per resolver collapses that to one tx per resolver. Each call returns an independent buffer, so concurrent resolvers (Azure runs them in an errgroup) each get their own — safe to use one buffered store per resolver goroutine. activeCounter is preserved, so the ReportResolveComplete edge tally is unaffected.
func (*Store) Close ¶
Close closes the underlying database connection. No-op for tx-bound Stores produced by WrapTx — the caller owns the transaction lifecycle.
For a writable SQLite store it first ends the WAL session cleanly: checkpoint the WAL tail into the main file, then switch journal_mode to DELETE so SQLite deterministically removes the -wal/-shm sidecars (the next Open re-enables WAL). Without this, SQLite's best-effort last-connection auto-delete — which needs an exclusive lock and is silently skipped if a reader lingers — is the source of orphaned sidecars. Errors are ignored: a held lock leaves the WAL in place (safely replayed on next open) rather than failing the command.
func (*Store) CompleteScan ¶
CompleteScan marks a scan as completed and records the resource count.
func (*Store) CountManaged ¶
CountManaged returns the count of provider-managed rows in resources — the population a customer-only query (IncludeManaged=false) hides. Used by `disco check` to print the excluded-managed count alongside the evaluated count so SecEng/Compliance personas don't misread the small denominator as "no resources".
func (*Store) CountResourcesByScan ¶
CountResourcesByScan returns the number of resources recorded under a scan ID.
func (*Store) CreateScan ¶
CreateScan inserts a new scan record with status "running" and returns its ID (freshly minted, 32-hex). Use CreateScanWithID when the caller (e.g. an external orchestrator) needs to assign the id ahead of time so its audit trail / resources / scans share a single identifier.
func (*Store) CreateScanWithID ¶
func (s *Store) CreateScanWithID(id string, providers []string, scope map[string]any) (string, error)
CreateScanWithID inserts a new scan record with the supplied id. An empty id falls back to freshly-minted 32-hex (CreateScan's legacy behaviour).
func (*Store) CurrentSchemaVersion ¶
CurrentSchemaVersion reads the highest applied migration from the on-disk schema_migrations table. Returns 0 when the table is missing or empty (a fresh-but-empty DB or a pre-migration-tracking checkout). Read-only callers pair this with TargetSchemaVersion to detect stale schemas and reject before issuing queries that would surface as cryptic SQLite errors.
func (*Store) DB ¶
DB returns the underlying sqlx.DB for use in packages that need direct access. Returns nil for tx-bound Stores produced by WrapTx.
func (*Store) DeleteCheckRun ¶
DeleteCheckRun removes the run and (via FK CASCADE) every finding row owned by it. Used by future retention-pruning paths.
func (*Store) DeleteScanCheckpoints ¶
DeleteScanCheckpoints removes every checkpoint for a scan. Called once the scan completes so the table stays bounded — abandoned scan rows live until explicit cleanup or the next resume of the same scan_id.
func (*Store) DescendantsOf ¶
func (s *Store) DescendantsOf(parentID string, f ResourceFilter) ([]Resource, error)
DescendantsOf returns all resources descended from parentID (any depth). Caller passes a deterministic ResourceID hash; hierarchy_closure stores hashes, so the join key on r is resourceIDColumn() (`root_id`).
func (*Store) DiffScans ¶
DiffScans returns the resource delta between two scan runs. See ScanDiff for semantics and limitations.
func (*Store) FlushRelBuffer ¶ added in v0.10.0
FlushRelBuffer writes all edges buffered since BeginRelBuffer in a single transaction and clears the buffer. No-op (nil) on a store without a buffer.
func (*Store) GetCheckRun ¶
GetCheckRun retrieves one check_run by ID. Decodes RulesPaths + Packs.
func (*Store) GetCheckpoint ¶
func (s *Store) GetCheckpoint(scanID, provider, service, scope string) (lastToken string, ok bool, err error)
GetCheckpoint returns the latest token for the (scan, provider, service, scope) tuple, or ("", false, nil) when no row exists. Resume logic should fall back to a fresh scan when ok=false.
func (*Store) GetResource ¶
GetResource retrieves a single resource by its caller-facing ID (the deterministic ResourceID hash). Resolves to the current version row of the chain whose root_id matches.
func (*Store) GetResourceVersions ¶
func (s *Store) GetResourceVersions(rootID string) ([]ResourceVersion, error)
GetResourceVersions walks the full version chain for a resource and returns every row in chronological order (root first). Surfaced via `disco history`.
func (*Store) GraphAll ¶
func (s *Store) GraphAll(opts GraphAllOpts) (*GraphResult, error)
GraphAll returns the whole resource graph in one shot — every customer resource plus every provider-managed resource that has at least one edge touching a customer resource. Edges are kept only when both endpoints survive filtering. SeedID is empty since there is no traversal seed.
Prefer GraphWalk / GraphPath for seeded queries; GraphAll's working set scales with the full resource + relationship table.
func (*Store) GraphPath ¶
func (s *Store) GraphPath(fromID, toID string, opts GraphPathOpts) (*GraphResult, error)
GraphPath returns the shortest edge sequence from fromID to toID using BFS across the relationships graph in the requested direction. Returns ErrNoPath if no route exists within the constraints. Nodes are returned in path order with Depth = distance from from. Edges are in path order.
func (*Store) GraphWalk ¶
func (s *Store) GraphWalk(seedID string, opts GraphWalkOpts) (*GraphResult, error)
GraphWalk does a bounded BFS starting at seedID, following edges in the requested direction(s) filtered by kind, and returns the reachable subgraph.
func (*Store) InsertResourcesIfAbsent ¶ added in v0.14.0
InsertResourcesIfAbsent inserts each resource only when no current-version row already holds its natural key, otherwise leaves the existing row untouched. Unlike UpsertResources it never runs the verify or version-split paths, so a populated row is never reduced back to a placeholder's attributes.
This is the reference-discovery primitive: a resolver that sees an edge into an account/subscription/project outside the current scan scope inserts an empty-attribute row at that resource's real self-node natural key, giving the edge a stable FK target (the deterministic root_id is identical whether the row is the empty placeholder or later fully populated). When that target is itself scanned — this run or a future one — its own scanner calls UpsertResources, finds the placeholder as the current version, and version-splits it to the populated form. The placeholder is preserved in history; the edge keeps resolving across the split.
Returns the count of rows actually inserted (placeholders whose natural key was already occupied don't count).
func (*Store) LatestCompleteScan ¶
LatestCompleteScan returns the most-recent scan with status "complete" or "partial" that names the given provider (or any provider when provider=""). Returns sql.ErrNoRows when no such scan exists. Used by `disco scan --if-older-than` to skip cron-driven re-scans when a recent run already covered the same provider scope.
func (*Store) LatestIncompleteScan ¶
LatestIncompleteScan returns the most-recent scan whose status is "running" or "partial" — the candidate for `disco scan --resume` to pick up. Returns (nil, nil) when no such scan exists. Status "failed" is excluded — those require manual triage rather than blind resume.
func (*Store) ListCheckRuns ¶
ListCheckRuns returns every check_run, newest first. Tie-break on rowid DESC so two runs created within the same SQLite-second order deterministically (mirrors ListScans).
func (*Store) ListCheckpoints ¶
func (s *Store) ListCheckpoints(scanID string) ([]Checkpoint, error)
ListCheckpoints returns every checkpoint for a scan, ordered by service then scope so callers iterate deterministically. Used by the --resume CLI to summarise pending work, and by a future incremental scanner that pre-fetches all checkpoints in one round-trip.
func (*Store) ListFindings ¶
func (s *Store) ListFindings(f FindingFilter) ([]StoredFinding, error)
ListFindings returns findings matching the filter. Sorted by severity rank (critical → low) then finding_id then resource_id for stable reporting.
func (*Store) ListRelationships ¶
func (s *Store) ListRelationships() ([]Relationship, error)
ListRelationships returns every edge in the store. Used by GraphAll for the unfiltered "complete graph" subcommand; per-seed walks should prefer RelationshipsFrom / RelationshipsTo so they don't pull the whole table.
func (*Store) ListResources ¶
func (s *Store) ListResources(f ResourceFilter) ([]Resource, error)
ListResources returns resources matching the given filters.
func (*Store) ListScans ¶
ListScans returns all scans ordered by start time descending. Providers is decoded per row so callers can render the slice without a follow-up GetScan fan-out.
func (*Store) PartialScan ¶
PartialScan marks a scan as partially completed: at least one provider succeeded while others failed. The error message should summarize which providers failed and why.
func (*Store) PersistCheckRun ¶
func (s *Store) PersistCheckRun(rulesPaths, packs []string, severityFilter string, resourceCount int, findings []StoredFinding) (string, error)
PersistCheckRun writes one check_runs row plus N findings rows in a single transaction. Returns the generated check_run ID. Caller pre- builds []StoredFinding (CheckRunID is filled in by this method) — keeps the store package free of a policy import which would cycle.
func (*Store) RecordHierarchy ¶
RecordHierarchy writes both halves of a parent/child relationship in one transaction:
- the child's self-entry in hierarchy_closure (depth 0) plus one closure row per ancestor of parent, extending depth+1, so the transitive closure stays O(1) for "all descendants of X" (org→folder→project produces nine rows: each node's self entry plus org→folder, org→project, folder→project);
- a depth-1 `parent → child contains` row in `relationships` so GraphWalk (relationships-only) sees the hierarchy edge — this unification is what makes Azure/GCP hierarchy visible in `disco graph`, since those providers record hierarchy via closure only.
Missing endpoints (a pair referencing a resource not in `resources`, e.g. an unscanned Azure RG) skip the relationship row and emit a ScanWarning via ReportWarning — operators see the drift, callers stay simple. Closure rows always go down regardless.
func (*Store) RecordHierarchyBatch ¶
RecordHierarchyBatch is the multi-pair form of RecordHierarchy. Pairs are `[2]string{childID, parentID}`. Missing-endpoint pairs collect into one ScanWarning at the end, so one warning per call surfaces drift without spamming busy scans.
func (*Store) RelationshipsFrom ¶
func (s *Store) RelationshipsFrom(fromID string, kinds ...string) ([]Relationship, error)
RelationshipsFrom returns all edges originating from a resource.
func (*Store) RelationshipsTo ¶
func (s *Store) RelationshipsTo(toID string, kinds ...string) ([]Relationship, error)
RelationshipsTo returns all edges pointing to a resource.
func (*Store) ReportError ¶
ReportError fires OnError if set. Providers call this when a service or resolver fails — the error is accumulated and rendered as a grouped block at the end of the scan, never aborting other in-flight work.
func (*Store) ReportResolveComplete ¶
ReportResolveComplete fires OnResolveComplete with the supplied edge count. Call this immediately after all resolvers finish.
func (*Store) ReportResolveStart ¶
ReportResolveStart fires OnResolveStart (if set). Call this immediately before the phase-2 resolver loop begins.
func (*Store) ReportService ¶
func (s *Store) ReportService(service, scope string, total, newCount, changed, errCount int, status ServiceStatus)
ReportService invokes OnServiceComplete if set, called after each service scan function returns. scope is the per-call dimension that would otherwise duplicate the line in multi-region / multi-account scans (AWS region or "global", Azure subscription ID, GCP project ID). total = resources seen this scan; newCount = resources discovered for the first time (never previously in the DB); changed = existing resources whose attributes or tags changed this scan (a version split); errCount = errors encountered scanning this service (>0 surfaces as a "(with errors)" suffix on the progress line). status = ServiceDisabled (not enabled in this account/project/subscription), ServiceNotEntitled (exists but the tenant can't self-enable it), or ServiceUnavailable (not deployed in this AWS region); each surfaces as a "(<scope>: <state>)" suffix, mutually exclusive with errCount>0 since a skipped service emits no errors.
func (*Store) ReportWarning ¶
func (s *Store) ReportWarning(w ScanWarning)
ReportWarning fires OnWarn if set. Providers call this from skip-handling helpers (skipIfAccessDenied, skipIfDenied) in place of log.Printf so that warnings can be collected and rendered as a single grouped block rather than interleaving with aligned progress output.
func (*Store) ResolveResource ¶
ResolveResource finds a resource by its 32-hex ID, an 8+-char hex prefix of an ID, an exact native_id/name, or a substring of native_id/name. Disambiguation flags (provider/rtype/account) narrow the result set; ambiguity surfaces as a multi-candidate error.
Two-pass matcher: exact native_id/name first, then prefix-on-id and substring-on-native_id/name, so paste-the-short-ID workflows ("the ticket says 8895a0bd") work the same as full IDs. F12 fix from focus-group/SUMMARY.md.
func (*Store) RestoreResource ¶ added in v0.15.0
RestoreResource lifts the archival tombstone on the current version of the resource chain identified by rootID, clearing deleted_at / deleted_by in place, and reports whether a tombstone was lifted.
The clear is done in place on the current row (matching how a scan re-sight resurrects via UpsertResources' verify-only path) rather than as another version split, so restoring leaves no extra chain entry.
It reports false with a nil error when rootID has no archived current row — the chain does not exist, or its current row is already live. The operation is therefore idempotent.
func (*Store) ReversedContainsEdges ¶
func (s *Store) ReversedContainsEdges() ([]Relationship, error)
ReversedContainsEdges returns `contains` rows where `to_id` is an ancestor of `from_id` per hierarchy_closure — i.e. edges pointing child→parent instead of the canonical parent→child. Invariant: always empty; non-empty means a scanner regressed and emits reversed direction. Used by the store-side test guarding against drift; CI fails on any non-empty return.
func (*Store) SaveCheckpoint ¶
SaveCheckpoint upserts a checkpoint row. lastToken may be empty (page returned no continuation token); callers persist that so resume logic distinguishes "completed" from "never started". Updates `updated_at` on every call so stale checkpoints can be detected.
func (*Store) UpsertRelationship ¶
UpsertRelationship inserts a relationship, ignoring conflicts (idempotent). On a buffered store (BeginRelBuffer) the edge is accumulated and written later by FlushRelBuffer in a single transaction instead of its own autocommit.
func (*Store) UpsertRelationships ¶ added in v0.10.0
UpsertRelationships upserts many relationships in a single transaction with a reused prepared statement — the batch form of UpsertRelationship. Idempotent (same ON CONFLICT). Empty input is a no-op. Callers that buffer via BeginRelBuffer reach this through FlushRelBuffer; it is also callable directly.
func (*Store) UpsertResource ¶
UpsertResource inserts or replaces a single resource. Delegates to UpsertResources, whose version-chain logic lives in resources_upsert.go.
func (*Store) UpsertResources ¶
func (*Store) WithRelCounter ¶
WithRelCounter returns a shallow copy of the Store with activeCounter set. UpsertRelationship increments the counter on each call. Providers create a local atomic.Int64, pass it here, then read it for ReportResolveComplete. Safe to shallow-copy because the struct contains no embedded sync/atomic values.
func (*Store) WithUpsertCounters ¶ added in v0.14.0
WithUpsertCounters returns a shallow copy of the Store with the new/changed upsert counters bound. UpsertResources bumps newC on each first-discovery and changedC on each version split. Providers create two local atomic.Int64 around a per-service scan dispatch, pass them here, then read them for ReportService — attributing the split per (service, scope) without threading a second count through every scanner signature. Mirrors WithRelCounter.
type StoredFinding ¶
type StoredFinding struct {
RowID int64 `db:"id"`
CheckRunID string `db:"check_run_id"`
FindingID string `db:"finding_id"`
ResourceID string `db:"resource_id"`
Severity string `db:"severity"`
Message string `db:"message"`
Provider *string `db:"provider"`
Type *string `db:"type"`
Name *string `db:"name"`
Region *string `db:"region"`
Category *string `db:"category"`
Remediation *string `db:"remediation"`
RefURL *string `db:"ref_url"`
TagsJSON *string `db:"tags"`
// WorkspaceID: per-workspace RLS discriminator, omitted from the read
// projection (findingColumns) like the other shared-table reads; stays nil.
WorkspaceID *string `db:"workspace_id"`
}
StoredFinding is the on-disk shape of a policy.Finding. Pointer fields allow NULL in the DB so optional Rego output keys round-trip cleanly.