diff --git a/cmd/graphnest-server/main.go b/cmd/graphnest-server/main.go index 56166f47..df0a2acf 100644 --- a/cmd/graphnest-server/main.go +++ b/cmd/graphnest-server/main.go @@ -44,6 +44,7 @@ import ( "github.com/balcsida/graphnest/internal/sso/oidc" "github.com/balcsida/graphnest/internal/supplychain" "github.com/balcsida/graphnest/internal/supplychain/license" + "github.com/balcsida/graphnest/internal/supplychain/review" "github.com/balcsida/graphnest/internal/webhook" "github.com/balcsida/graphnest/internal/webui" "github.com/balcsida/graphnest/internal/zoekt" @@ -483,10 +484,13 @@ func newDurableRuntime(ctx context.Context, settings config.Config, logger *slog } return repo.ID, err }} + reviews := &review.Service{Store: store, Authorizer: authorizer, MaxResults: settings.Limits.MaxResults} + supplyChainDone = append(supplyChainDone, startPolicyEvaluation(loopCtx, reviews, logger)) extras = append(extras, func(mux *http.ServeMux) { httpapi.RegisterSupplyChain(mux, auth.requestAuth, supplyChainService, settings.Limits.MaxResults, settings.Limits.MaxResponseBytes) httpapi.RegisterSupplyChainPortfolio(mux, auth.requestAuth, portfolio, settings.Limits.MaxResults, settings.Limits.MaxResponseBytes, &httpapi.DerivedExport{Service: supplyChainService, PublicOrigin: auth.requestAuth.PublicOrigin}) httpapi.RegisterSupplyChainImports(mux, auth.requestAuth, importer, grants, settings.SupplyChain.MaxDocumentBytes, settings.Limits.MaxResponseBytes) + httpapi.RegisterSupplyChainReview(mux, auth.requestAuth, reviews, settings.Limits.MaxRequestBytes, settings.Limits.MaxResponseBytes) }) } handler := newAPIHandler(settings, metrics, auth.requestAuth, searchService, repositoryService, scipService, graphService, graphQueries, webhookSecret, processor, adminService, durableReadiness{pool: pool, zoekt: backend}, auth.providers, auth.sessions, provisioning, scimService, auth.mcpOAuth, extras...) @@ -507,6 +511,35 @@ func newDurableRuntime(ctx context.Context, settings config.Config, logger *slog }, nil } +// startPolicyEvaluation periodically applies the active policy to components +// whose evidence changed or that were never evaluated. Historical results are +// retained as non-current rows. +func startPolicyEvaluation(ctx context.Context, reviews *review.Service, logger *slog.Logger) <-chan struct{} { + done := make(chan struct{}) + go func() { + defer close(done) + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + for { + evaluated, err := reviews.EvaluatePending(ctx, 500) + if err != nil && ctx.Err() == nil { + logger.Error("supply chain policy evaluation failed", "error", err) + } + if evaluated < 500 || err != nil { + break + } + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } + }() + return done +} + // startSupplyChain runs the inventory scheduler and collection workers inside // the server process. They share nothing with the indexer, so inventory work // can neither block nor be blocked by lexical indexing (ADR-0017). diff --git a/docs/openapi.yaml b/docs/openapi.yaml index da0dff8f..8a47ab8a 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -914,6 +914,119 @@ paths: '405': {$ref: '#/components/responses/InvalidRequest'} '413': {description: Export exceeds the response limit} '503': {$ref: '#/components/responses/Unavailable'} + /v1/supply-chain/review/queue: + get: + description: Occurrences in the caller's authorized repositories whose current policy verdict is not approved and that have no current decision, or whose decision is stale (exception expired, or evidence changed since the decision). Each item carries the assessment basis fingerprint a reviewer must echo back. + security: [{bearerAuth: []}, {sessionCookie: []}] + parameters: + - {name: stream, in: query, required: false, schema: {type: string}} + - {name: cursor, in: query, required: false, schema: {type: string, minLength: 1}} + - {name: limit, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 100}} + responses: + '200': {description: Review queue, content: {application/json: {schema: {$ref: '#/components/schemas/SupplyChainReviewQueue'}}}} + '400': {$ref: '#/components/responses/InvalidRequest'} + '401': {$ref: '#/components/responses/Unauthenticated'} + '405': {$ref: '#/components/responses/InvalidRequest'} + '500': {description: Response exceeded the configured byte limit} + '503': {$ref: '#/components/responses/Unavailable'} + /v1/supply-chain/review/conclusions: + post: + description: Record a human license conclusion for exact coordinates. Requires read access to the repository plus a review grant (administrators need no grant). The expression must be a valid SPDX expression with known identifiers, NONE, or UNLICENSED. The basis must equal the current assessment fingerprint of an occurrence in the repository (409 stale_basis otherwise). The conclusion is stored as 'human' evidence; automated evidence is retained and any disagreement stays visible in the assessment's conflict detail. + security: [{bearerAuth: []}, {sessionCookie: []}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/SupplyChainConcludeRequest'}}}} + responses: + '201': {description: Conclusion recorded, content: {application/json: {schema: {$ref: '#/components/schemas/SupplyChainConclusion'}}}} + '400': {$ref: '#/components/responses/InvalidRequest'} + '401': {$ref: '#/components/responses/Unauthenticated'} + '403': {description: Review permission required, content: {application/json: {schema: {$ref: '#/components/schemas/ErrorResponse'}}}} + '404': {$ref: '#/components/responses/NotFound'} + '405': {$ref: '#/components/responses/InvalidRequest'} + '409': {description: Evidence changed since it was viewed (stale_basis), content: {application/json: {schema: {$ref: '#/components/schemas/ErrorResponse'}}}} + '413': {$ref: '#/components/responses/InvalidRequest'} + '415': {$ref: '#/components/responses/InvalidRequest'} + '503': {$ref: '#/components/responses/Unavailable'} + /v1/supply-chain/review/decisions: + post: + description: Record a scoped usage decision (approve, reject, or an exception with an expiry within one year) for exact coordinates in one repository. Same permission and basis rules as conclusions. The decision records the active policy version and current verdict; an exception never erases a prohibited verdict or rewrites the license. A newer decision supersedes the previous one; prior decisions remain in history. + security: [{bearerAuth: []}, {sessionCookie: []}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/SupplyChainDecideRequest'}}}} + responses: + '201': {description: Decision recorded, content: {application/json: {schema: {$ref: '#/components/schemas/SupplyChainDecision'}}}} + '400': {$ref: '#/components/responses/InvalidRequest'} + '401': {$ref: '#/components/responses/Unauthenticated'} + '403': {description: Review permission required, content: {application/json: {schema: {$ref: '#/components/schemas/ErrorResponse'}}}} + '404': {$ref: '#/components/responses/NotFound'} + '405': {$ref: '#/components/responses/InvalidRequest'} + '409': {description: Evidence changed since it was viewed (stale_basis), content: {application/json: {schema: {$ref: '#/components/schemas/ErrorResponse'}}}} + '413': {$ref: '#/components/responses/InvalidRequest'} + '415': {$ref: '#/components/responses/InvalidRequest'} + '503': {$ref: '#/components/responses/Unavailable'} + /v1/supply-chain/review/history: + get: + description: Conclusions, decisions (current and superseded), and policy evaluation history for exact coordinates in one authorized repository, plus whether the current decision is stale. + security: [{bearerAuth: []}, {sessionCookie: []}] + parameters: + - {name: repository_id, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} + - {name: ecosystem, in: query, required: true, schema: {type: string}} + - {name: namespace, in: query, required: false, schema: {type: string}} + - {name: name, in: query, required: true, schema: {type: string}} + - {name: version, in: query, required: true, schema: {type: string}} + responses: + '200': {description: Review history, content: {application/json: {schema: {$ref: '#/components/schemas/SupplyChainReviewHistory'}}}} + '400': {$ref: '#/components/responses/InvalidRequest'} + '401': {$ref: '#/components/responses/Unauthenticated'} + '404': {$ref: '#/components/responses/NotFound'} + '405': {$ref: '#/components/responses/InvalidRequest'} + '500': {description: Response exceeded the configured byte limit} + '503': {$ref: '#/components/responses/Unavailable'} + /v1/supply-chain/review/grants: + put: + description: Administrator-only. Grant or revoke a subject's review capability for one repository. Reviewers still need read access to the repository and cannot grant themselves. + security: [{bearerAuth: []}, {sessionCookie: []}] + requestBody: {required: true, content: {application/json: {schema: {type: object, additionalProperties: false, required: [repository_id, subject, allow], properties: {repository_id: {type: integer, format: int64, minimum: 1}, subject: {type: string, minLength: 1, maxLength: 256}, allow: {type: boolean}}}}}} + responses: + '204': {description: Grant updated} + '400': {$ref: '#/components/responses/InvalidRequest'} + '401': {$ref: '#/components/responses/Unauthenticated'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '405': {$ref: '#/components/responses/InvalidRequest'} + '413': {$ref: '#/components/responses/InvalidRequest'} + '415': {$ref: '#/components/responses/InvalidRequest'} + '503': {$ref: '#/components/responses/Unavailable'} + /v1/supply-chain/review/events: + get: + description: Append-only review audit events (policy changes, grants, conclusions, decisions) for the caller's authorized repositories. + security: [{bearerAuth: []}, {sessionCookie: []}] + responses: + '200': {description: Audit events, newest first, content: {application/json: {schema: {type: object, additionalProperties: false, required: [events], properties: {events: {type: array, maxItems: 100, items: {$ref: '#/components/schemas/SupplyChainReviewEvent'}}}}}}} + '401': {$ref: '#/components/responses/Unauthenticated'} + '405': {$ref: '#/components/responses/InvalidRequest'} + '500': {description: Response exceeded the configured byte limit} + '503': {$ref: '#/components/responses/Unavailable'} + /v1/supply-chain/policies: + get: + description: List policy versions (newest first). Exactly one organization policy may be active; the shipped example is labelled kind=example. + security: [{bearerAuth: []}, {sessionCookie: []}] + responses: + '200': {description: Policies, content: {application/json: {schema: {type: object, additionalProperties: false, required: [policies], properties: {policies: {type: array, maxItems: 100, items: {$ref: '#/components/schemas/SupplyChainPolicy'}}}}}}} + '401': {$ref: '#/components/responses/Unauthenticated'} + '405': {$ref: '#/components/responses/InvalidRequest'} + '500': {description: Response exceeded the configured byte limit} + '503': {$ref: '#/components/responses/Unavailable'} + post: + description: Administrator-only. Create a new immutable policy version (or install the labelled example fixture). unknown_handling must be review_required or prohibited; approved is refused so unknown licensing never auto-approves. Activating a policy deactivates the previous one; components are re-evaluated in the background and historical results are retained. + security: [{bearerAuth: []}, {sessionCookie: []}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/SupplyChainCreatePolicyRequest'}}}} + responses: + '201': {description: Policy created, content: {application/json: {schema: {$ref: '#/components/schemas/SupplyChainPolicy'}}}} + '400': {$ref: '#/components/responses/InvalidRequest'} + '401': {$ref: '#/components/responses/Unauthenticated'} + '403': {$ref: '#/components/responses/Forbidden'} + '405': {$ref: '#/components/responses/InvalidRequest'} + '413': {$ref: '#/components/responses/InvalidRequest'} + '415': {$ref: '#/components/responses/InvalidRequest'} + '503': {$ref: '#/components/responses/Unavailable'} /v1/auth/config: get: responses: @@ -2601,6 +2714,159 @@ components: subject: {type: string, enum: [source, artifact]} has_inventory: {type: boolean} last_outcome: {type: string} + SupplyChainCoordinates: + type: object + additionalProperties: false + required: [ecosystem, name, version] + properties: + ecosystem: {type: string, minLength: 1} + namespace: {type: string} + name: {type: string, minLength: 1} + version: {type: string, minLength: 1} + SupplyChainReviewItem: + type: object + additionalProperties: false + required: [component_id, snapshot_id, repository_id, repository, element_id, name, version, coordinates, assessment, verdict, explanation, policy_id, stale_decision_id, reason] + properties: + component_id: {type: integer, format: int64} + snapshot_id: {type: integer, format: int64} + repository_id: {type: integer, format: int64} + repository: {type: string} + element_id: {type: string} + name: {type: string} + version: {type: string} + purl: {type: string} + coordinates: {$ref: '#/components/schemas/SupplyChainCoordinates'} + assessment: {type: string} + expression: {type: string} + basis: {type: string, description: Assessment evidence fingerprint (hex) to echo back in conclusions and decisions} + verdict: {type: string, enum: ['', approved, prohibited, review_required, unknown]} + explanation: {type: string} + policy_id: {oneOf: [{type: integer, format: int64}, {type: 'null'}]} + stale_decision_id: {oneOf: [{type: integer, format: int64}, {type: 'null'}]} + reason: {type: string, enum: [no decision recorded, not yet evaluated, exception expired, evidence changed since the decision]} + SupplyChainReviewQueue: + type: object + additionalProperties: false + required: [items, truncated] + properties: + items: {type: array, maxItems: 100, items: {$ref: '#/components/schemas/SupplyChainReviewItem'}} + truncated: {type: boolean} + next_cursor: {type: string, minLength: 1} + SupplyChainConcludeRequest: + type: object + additionalProperties: false + required: [repository_id, coordinates, expression, reason, basis] + properties: + repository_id: {type: integer, format: int64, minimum: 1} + coordinates: {$ref: '#/components/schemas/SupplyChainCoordinates'} + expression: {type: string, minLength: 1, description: SPDX expression with known identifiers, NONE, or UNLICENSED} + reason: {type: string, minLength: 1, maxLength: 4096} + basis: {type: string, pattern: '^[0-9a-f]{64}$'} + SupplyChainDecideRequest: + type: object + additionalProperties: false + required: [repository_id, coordinates, kind, reason, basis] + properties: + repository_id: {type: integer, format: int64, minimum: 1} + coordinates: {$ref: '#/components/schemas/SupplyChainCoordinates'} + kind: {type: string, enum: [approve, reject, exception]} + reason: {type: string, minLength: 1, maxLength: 4096} + usage_context: {type: string, maxLength: 1024} + expires_at: {type: string, format: date-time, description: Required for exception (within one year); forbidden otherwise} + basis: {type: string, pattern: '^[0-9a-f]{64}$'} + SupplyChainConclusion: + type: object + additionalProperties: false + required: [id, coordinates, evidence_id, basis, reviewer, reason, created_at, superseded_by] + properties: + id: {type: integer, format: int64} + coordinates: {$ref: '#/components/schemas/SupplyChainCoordinates'} + evidence_id: {type: integer, format: int64} + basis: {type: string} + reviewer: {type: string} + reason: {type: string} + created_at: {type: string, format: date-time} + superseded_by: {oneOf: [{type: integer, format: int64}, {type: 'null'}]} + SupplyChainDecision: + type: object + additionalProperties: false + required: [id, coordinates, kind, policy_id, basis, reviewer, reason, expires_at, created_at, superseded_by] + properties: + id: {type: integer, format: int64} + coordinates: {$ref: '#/components/schemas/SupplyChainCoordinates'} + kind: {type: string, enum: [approve, reject, exception]} + policy_id: {oneOf: [{type: integer, format: int64}, {type: 'null'}]} + policy_verdict: {type: string} + basis: {type: string} + reviewer: {type: string} + reason: {type: string} + usage_context: {type: string} + expires_at: {oneOf: [{type: string, format: date-time}, {type: 'null'}]} + created_at: {type: string, format: date-time} + superseded_by: {oneOf: [{type: integer, format: int64}, {type: 'null'}]} + SupplyChainPolicyResult: + type: object + additionalProperties: false + required: [policy_id, verdict, explanation, evaluated_at] + properties: + policy_id: {type: integer, format: int64} + verdict: {type: string, enum: [approved, prohibited, review_required, unknown]} + explanation: {type: string} + evaluated_at: {type: string, format: date-time} + evidence_fingerprint: {type: string} + SupplyChainReviewHistory: + type: object + additionalProperties: false + required: [current, current_stale, conclusions, decisions, policy_results] + properties: + basis: {type: string} + current: {oneOf: [{$ref: '#/components/schemas/SupplyChainDecision'}, {type: 'null'}]} + current_stale: {type: boolean} + stale_reason: {type: string} + conclusions: {type: array, items: {$ref: '#/components/schemas/SupplyChainConclusion'}} + decisions: {type: array, items: {$ref: '#/components/schemas/SupplyChainDecision'}} + policy_results: {type: array, items: {$ref: '#/components/schemas/SupplyChainPolicyResult'}} + SupplyChainReviewEvent: + type: object + additionalProperties: false + required: [id, kind, actor, repository_id, target, detail, created_at] + properties: + id: {type: integer, format: int64} + kind: {type: string} + actor: {type: string} + repository_id: {oneOf: [{type: integer, format: int64}, {type: 'null'}]} + target: {type: string} + detail: {type: object} + created_at: {type: string, format: date-time} + SupplyChainPolicy: + type: object + additionalProperties: false + required: [id, name, version, kind, active, unknown_handling, description, created_by, approved, review_required, prohibited, allow_or_later] + properties: + id: {type: integer, format: int64} + name: {type: string} + version: {type: integer, minimum: 1} + kind: {type: string, enum: [example, organization]} + active: {type: boolean} + unknown_handling: {type: string, enum: [review_required, prohibited]} + description: {type: string} + created_by: {type: string} + approved: {type: array, items: {type: string}} + review_required: {type: array, items: {type: string}} + prohibited: {type: array, items: {type: string}} + allow_or_later: {type: boolean} + SupplyChainCreatePolicyRequest: + type: object + additionalProperties: false + required: [activate] + properties: + name: {type: string, minLength: 1, maxLength: 128} + description: {type: string, maxLength: 4096} + rules: {type: object, additionalProperties: false, properties: {approved: {type: array, items: {type: string}}, review_required: {type: array, items: {type: string}}, prohibited: {type: array, items: {type: string}}, allow_or_later: {type: boolean}}} + unknown_handling: {type: string, enum: [review_required, prohibited]} + activate: {type: boolean} + install_example: {type: boolean, description: Install the labelled example fixture instead of authoring rules} SupplyChainComponentList: type: object additionalProperties: false diff --git a/internal/httpapi/supply_chain_review.go b/internal/httpapi/supply_chain_review.go new file mode 100644 index 00000000..e15fa7c3 --- /dev/null +++ b/internal/httpapi/supply_chain_review.go @@ -0,0 +1,220 @@ +package httpapi + +import ( + "encoding/hex" + "errors" + "net/http" + "strconv" + + "github.com/balcsida/graphnest/internal/authn" + "github.com/balcsida/graphnest/internal/supplychain/license" + "github.com/balcsida/graphnest/internal/supplychain/policy" + "github.com/balcsida/graphnest/internal/supplychain/review" + "github.com/balcsida/graphnest/pkg/api" +) + +// RegisterSupplyChainReview mounts the review queue, conclusions, decisions, +// history, grants, policies, and audit routes. +func RegisterSupplyChainReview(mux *http.ServeMux, authenticator authn.RequestAuthenticator, service *review.Service, maxRequestBytes, maxResponseBytes int64) { + authenticated := func(method string, handle http.Handler) http.Handler { + return exactMethod(method, AuthenticateRequest(authenticator, handle)) + } + mux.Handle("/v1/supply-chain/review/queue", authenticated(http.MethodGet, http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + query := request.URL.Query() + var after int64 + if value := query.Get("cursor"); value != "" { + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil || parsed < 1 { + writeError(writer, http.StatusBadRequest, "invalid_request", "request is invalid", false) + return + } + after = parsed + } + limit := 0 + if value := query.Get("limit"); value != "" { + parsed, err := strconv.Atoi(value) + if err != nil || parsed < 1 || parsed > 100 { + writeError(writer, http.StatusBadRequest, "invalid_request", "request is invalid", false) + return + } + limit = parsed + } + items, truncated, err := service.Queue(request.Context(), PrincipalFromContext(request.Context()), query.Get("stream"), after, limit) + if err != nil { + writeReviewError(writer, err) + return + } + response := api.SupplyChainReviewQueue{Items: make([]api.SupplyChainReviewItem, 0, len(items)), Truncated: truncated} + for _, item := range items { + response.Items = append(response.Items, queueItem(item)) + } + if truncated && len(items) > 0 { + response.NextCursor = strconv.FormatInt(items[len(items)-1].ComponentID, 10) + } + writeBoundedJSON(writer, response, maxResponseBytes) + }))) + mux.Handle("/v1/supply-chain/review/conclusions", authenticated(http.MethodPost, jsonSCIPHandler(maxRequestBytes, func(writer http.ResponseWriter, request *http.Request, input api.SupplyChainConcludeRequest) { + conclusion, err := service.Conclude(request.Context(), PrincipalFromContext(request.Context()), review.ConcludeRequest{ + RepositoryID: input.RepositoryID, Coordinates: coordinates(input.Coordinates), Expression: input.Expression, Reason: input.Reason, BasisFingerprint: input.BasisFingerprint, + }) + if err != nil { + writeReviewError(writer, err) + return + } + writeBoundedJSONStatus(writer, http.StatusCreated, conclusionSummary(conclusion), maxResponseBytes) + }))) + mux.Handle("/v1/supply-chain/review/decisions", authenticated(http.MethodPost, jsonSCIPHandler(maxRequestBytes, func(writer http.ResponseWriter, request *http.Request, input api.SupplyChainDecideRequest) { + decision, err := service.Decide(request.Context(), PrincipalFromContext(request.Context()), review.DecideRequest{ + RepositoryID: input.RepositoryID, Coordinates: coordinates(input.Coordinates), Kind: review.DecisionKind(input.Kind), Reason: input.Reason, UsageContext: input.UsageContext, + ExpiresAt: input.ExpiresAt, BasisFingerprint: input.BasisFingerprint, + }) + if err != nil { + writeReviewError(writer, err) + return + } + writeBoundedJSONStatus(writer, http.StatusCreated, decisionSummary(decision), maxResponseBytes) + }))) + mux.Handle("/v1/supply-chain/review/history", authenticated(http.MethodGet, http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + query := request.URL.Query() + githubID, err := strconv.ParseInt(query.Get("repository_id"), 10, 64) + if err != nil { + writeError(writer, http.StatusBadRequest, "invalid_request", "request is invalid", false) + return + } + history, err := service.History(request.Context(), PrincipalFromContext(request.Context()), githubID, license.Coordinates{Ecosystem: query.Get("ecosystem"), Namespace: query.Get("namespace"), Name: query.Get("name"), Version: query.Get("version")}) + if err != nil { + writeReviewError(writer, err) + return + } + response := api.SupplyChainReviewHistory{Basis: history.Basis, Conclusions: []api.SupplyChainConclusion{}, Decisions: []api.SupplyChainDecision{}, PolicyResults: []api.SupplyChainPolicyResult{}, CurrentStale: history.CurrentStale, StaleReason: history.StaleReason} + for _, conclusion := range history.Conclusions { + response.Conclusions = append(response.Conclusions, conclusionSummary(conclusion)) + } + for _, decision := range history.Decisions { + response.Decisions = append(response.Decisions, decisionSummary(decision)) + } + if history.Current != nil { + current := decisionSummary(*history.Current) + response.Current = ¤t + } + for _, result := range history.PolicyResults { + response.PolicyResults = append(response.PolicyResults, api.SupplyChainPolicyResult{PolicyID: result.PolicyID, Verdict: string(result.Verdict), Explanation: result.Explanation, EvaluatedAt: result.EvaluatedAt, EvidenceFingerprint: hex.EncodeToString(result.EvidenceFingerprint)}) + } + writeBoundedJSON(writer, response, maxResponseBytes) + }))) + mux.Handle("/v1/supply-chain/review/grants", authenticated(http.MethodPut, jsonSCIPHandler(4<<10, func(writer http.ResponseWriter, request *http.Request, input struct { + RepositoryID int64 `json:"repository_id"` + Subject string `json:"subject"` + Allow bool `json:"allow"` + }) { + if err := service.SetReviewGrant(request.Context(), PrincipalFromContext(request.Context()), input.RepositoryID, input.Subject, input.Allow); err != nil { + writeReviewError(writer, err) + return + } + writer.WriteHeader(http.StatusNoContent) + }))) + mux.Handle("/v1/supply-chain/review/events", authenticated(http.MethodGet, http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + events, err := service.Events(request.Context(), PrincipalFromContext(request.Context())) + if err != nil { + writeReviewError(writer, err) + return + } + response := struct { + Events []api.SupplyChainReviewEvent `json:"events"` + }{Events: make([]api.SupplyChainReviewEvent, 0, len(events))} + for _, event := range events { + response.Events = append(response.Events, api.SupplyChainReviewEvent{ID: event.ID, Kind: event.Kind, Actor: event.Actor, RepositoryID: event.RepositoryID, Target: event.Target, Detail: event.Detail, CreatedAt: event.CreatedAt}) + } + writeBoundedJSON(writer, response, maxResponseBytes) + }))) + mux.Handle("/v1/supply-chain/policies", http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.Method { + case http.MethodGet: + AuthenticateRequest(authenticator, http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + policies, err := service.Policies(request.Context(), PrincipalFromContext(request.Context())) + if err != nil { + writeReviewError(writer, err) + return + } + response := struct { + Policies []api.SupplyChainPolicy `json:"policies"` + }{Policies: make([]api.SupplyChainPolicy, 0, len(policies))} + for _, item := range policies { + response.Policies = append(response.Policies, policySummary(item)) + } + writeBoundedJSON(writer, response, maxResponseBytes) + })).ServeHTTP(writer, request) + case http.MethodPost: + AuthenticateRequest(authenticator, jsonSCIPHandler(maxRequestBytes, func(writer http.ResponseWriter, request *http.Request, input api.SupplyChainCreatePolicyRequest) { + var created policy.Policy + var err error + if input.InstallExample { + created, err = service.InstallExamplePolicy(request.Context(), PrincipalFromContext(request.Context()), input.Activate) + } else { + created, err = service.CreatePolicy(request.Context(), PrincipalFromContext(request.Context()), input.Name, input.Description, input.Rules, policy.Verdict(input.UnknownHandling), input.Activate) + } + if err != nil { + writeReviewError(writer, err) + return + } + writeBoundedJSONStatus(writer, http.StatusCreated, policySummary(created), maxResponseBytes) + })).ServeHTTP(writer, request) + default: + writer.Header().Set("Allow", "GET, POST") + writeError(writer, http.StatusMethodNotAllowed, "invalid_request", "request is invalid", false) + } + })) +} + +func coordinates(input api.SupplyChainCoordinates) license.Coordinates { + return license.Coordinates{Ecosystem: input.Ecosystem, Namespace: input.Namespace, Name: input.Name, Version: input.Version} +} + +func apiCoordinates(input license.Coordinates) api.SupplyChainCoordinates { + return api.SupplyChainCoordinates{Ecosystem: input.Ecosystem, Namespace: input.Namespace, Name: input.Name, Version: input.Version} +} + +func queueItem(item review.QueueItem) api.SupplyChainReviewItem { + result := api.SupplyChainReviewItem{ComponentID: item.ComponentID, SnapshotID: item.SnapshotID, RepositoryID: item.RepositoryGitHubID, Repository: item.Repository, ElementID: item.ElementID, Name: item.Name, Version: item.Version, PURL: item.PURL, + Coordinates: apiCoordinates(item.Coordinates), Assessment: string(item.AssessmentStatus), Expression: item.Expression, Verdict: string(item.Verdict), Explanation: item.Explanation, PolicyID: item.PolicyID, StaleDecisionID: item.StaleDecisionID, Reason: item.Reason} + if len(item.EvidenceFingerprint) > 0 { + result.Basis = hex.EncodeToString(item.EvidenceFingerprint) + } + return result +} + +func conclusionSummary(conclusion review.Conclusion) api.SupplyChainConclusion { + return api.SupplyChainConclusion{ID: conclusion.ID, Coordinates: apiCoordinates(conclusion.Coordinates), EvidenceID: conclusion.EvidenceID, Basis: hex.EncodeToString(conclusion.BasisFingerprint), Reviewer: conclusion.Reviewer, Reason: conclusion.Reason, CreatedAt: conclusion.CreatedAt, SupersededBy: conclusion.SupersededBy} +} + +func decisionSummary(decision review.Decision) api.SupplyChainDecision { + return api.SupplyChainDecision{ID: decision.ID, Coordinates: apiCoordinates(decision.Coordinates), Kind: string(decision.Kind), PolicyID: decision.PolicyID, PolicyVerdict: string(decision.PolicyVerdict), Basis: hex.EncodeToString(decision.EvidenceFingerprint), + Reviewer: decision.Reviewer, Reason: decision.Reason, UsageContext: decision.UsageContext, ExpiresAt: decision.ExpiresAt, CreatedAt: decision.CreatedAt, SupersededBy: decision.SupersededBy} +} + +func policySummary(item policy.Policy) api.SupplyChainPolicy { + return api.SupplyChainPolicy{ID: item.ID, Name: item.Name, Version: item.Version, Kind: item.Kind, Active: item.Active, UnknownHandling: string(item.UnknownHandling), Description: item.Description, CreatedBy: item.CreatedBy, + Approved: nonNil(item.Rules.Approved), ReviewRequired: nonNil(item.Rules.ReviewRequired), Prohibited: nonNil(item.Rules.Prohibited), AllowOrLater: item.Rules.AllowOrLater} +} + +func nonNil(values []string) []string { + if values == nil { + return []string{} + } + return values +} + +func writeReviewError(writer http.ResponseWriter, err error) { + switch { + case errors.Is(err, review.ErrForbidden): + writeError(writer, http.StatusForbidden, "forbidden", "review permission for this repository is required", false) + case errors.Is(err, review.ErrStaleBasis): + writeError(writer, http.StatusConflict, "stale_basis", "the evidence changed since it was viewed; reload and review the current evidence", false) + case errors.Is(err, review.ErrInvalidRequest): + writeError(writer, http.StatusBadRequest, "invalid_request", "request is invalid", false) + case errors.Is(err, review.ErrNotFound): + writeError(writer, http.StatusNotFound, "not_found", "not found", false) + default: + writeSupplyChainError(writer, err) + } +} diff --git a/internal/postgres/migrations/036_supply_chain_review.sql b/internal/postgres/migrations/036_supply_chain_review.sql new file mode 100644 index 00000000..c9fcb25e --- /dev/null +++ b/internal/postgres/migrations/036_supply_chain_review.sql @@ -0,0 +1,109 @@ +-- Review workflows and license policies (ADR-0017, Milestone 5). Three +-- separate record kinds: evidence corrections / human conclusions, +-- versioned organization policies with evaluation results, and scoped usage +-- decisions (approve / reject / exception). Prior records are never edited; +-- a newer record supersedes an older one. + +-- Versioned policies. A policy is immutable once created; a new version is a +-- new row. Rules are stored as JSON and evaluated over the SPDX expression +-- tree, never over a flattened bag of names. +create table supply_chain_policies ( + id bigint generated always as identity primary key, + name varchar(128) not null, + version integer not null check (version > 0), + -- 'example' policies are fixtures shipped for demonstration; 'organization' are operator-authored. + kind varchar(16) not null check (kind in ('example', 'organization')), + rules jsonb not null, + unknown_handling varchar(16) not null check (unknown_handling in ('review_required', 'prohibited')), + description text not null default '', + created_by varchar(256) not null, + created_at timestamptz not null default now(), + active boolean not null default false, + unique (name, version) +); +-- At most one active organization policy. +create unique index supply_chain_policies_one_active on supply_chain_policies (active) where active; + +-- Human license conclusions for exact coordinates (or one occurrence). A +-- conclusion is evidence of source 'human' in the evidence table plus this +-- linkage that records its basis so later evidence changes are detectable. +create table supply_chain_conclusions ( + id bigint generated always as identity primary key, + ecosystem varchar(64) not null, + namespace text not null default '', + name text not null, + version text not null, + evidence_id bigint not null references supply_chain_license_evidence(id), + evidence_fingerprint bytea not null check (octet_length(evidence_fingerprint) = 32), + reviewer varchar(256) not null, + reason text not null, + created_at timestamptz not null default now(), + superseded_by bigint references supply_chain_conclusions(id) +); +create index supply_chain_conclusions_coordinates on supply_chain_conclusions (ecosystem, namespace, name, version, id desc); + +-- Policy evaluation results per occurrence per policy version; historical +-- results are retained. +create table supply_chain_policy_results ( + id bigint generated always as identity primary key, + component_id bigint not null references supply_chain_components(id) on delete cascade, + snapshot_id bigint not null references supply_chain_snapshots(id) on delete cascade, + policy_id bigint not null references supply_chain_policies(id), + evidence_fingerprint bytea check (evidence_fingerprint is null or octet_length(evidence_fingerprint) = 32), + verdict varchar(16) not null check (verdict in ('approved', 'prohibited', 'review_required', 'unknown')), + explanation text not null, + evaluated_at timestamptz not null default now(), + current boolean not null default true +); +create unique index supply_chain_policy_results_current on supply_chain_policy_results (component_id) where current; +create index supply_chain_policy_results_snapshot on supply_chain_policy_results (snapshot_id, verdict) where current; + +-- Scoped usage decisions. Scope is the exact coordinates within one +-- repository (usage context). A decision records the policy version and +-- evidence fingerprint it was made against; a later evidence change does not +-- erase it but marks it stale for re-review. +create table supply_chain_decisions ( + id bigint generated always as identity primary key, + repository_id bigint not null references repositories(id) on delete cascade, + ecosystem varchar(64) not null, + namespace text not null default '', + name text not null, + version text not null, + kind varchar(16) not null check (kind in ('approve', 'reject', 'exception')), + policy_id bigint references supply_chain_policies(id), + policy_verdict varchar(16) not null default '', + evidence_fingerprint bytea not null check (octet_length(evidence_fingerprint) = 32), + reviewer varchar(256) not null, + reason text not null, + usage_context text not null default '', + expires_at timestamptz, + created_at timestamptz not null default now(), + superseded_by bigint references supply_chain_decisions(id), + check (kind <> 'exception' or expires_at is not null) +); +create index supply_chain_decisions_scope on supply_chain_decisions (repository_id, ecosystem, namespace, name, version, id desc); +create index supply_chain_decisions_expiry on supply_chain_decisions (expires_at) where expires_at is not null and superseded_by is null; + +-- Reviewer capability: repository-scoped like upload grants. Administrators +-- need no grant. Reviewers still need read access to the repository. +create table supply_chain_review_grants ( + repository_id bigint not null references repositories(id) on delete cascade, + subject varchar(256) not null, + granted_by varchar(256) not null, + created_at timestamptz not null default now(), + primary key (repository_id, subject) +); + +-- Append-only review audit trail. +create table supply_chain_review_events ( + id bigint generated always as identity primary key, + kind varchar(32) not null, + actor varchar(256) not null, + repository_id bigint, + target text not null, + detail jsonb not null default '{}', + created_at timestamptz not null default now() +); +create trigger supply_chain_review_events_append_only +before update or delete or truncate on supply_chain_review_events +for each statement execute function reject_audit_event_mutation(); diff --git a/internal/postgres/supply_chain_review.go b/internal/postgres/supply_chain_review.go new file mode 100644 index 00000000..a92426b1 --- /dev/null +++ b/internal/postgres/supply_chain_review.go @@ -0,0 +1,422 @@ +package postgres + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/balcsida/graphnest/internal/supplychain/license" + "github.com/balcsida/graphnest/internal/supplychain/policy" + "github.com/balcsida/graphnest/internal/supplychain/review" + "github.com/jackc/pgx/v5" +) + +// CreatePolicy inserts an immutable policy version. When activate is true it +// deactivates the current active policy in the same transaction. +func (s *Store) CreatePolicy(ctx context.Context, item policy.Policy, activate bool) (policy.Policy, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return policy.Policy{}, err + } + defer tx.Rollback(ctx) + rules, err := json.Marshal(item.Rules) + if err != nil { + return policy.Policy{}, err + } + var version int + if err := tx.QueryRow(ctx, `select coalesce(max(version), 0)+1 from supply_chain_policies where name=$1`, item.Name).Scan(&version); err != nil { + return policy.Policy{}, err + } + if activate { + if _, err := tx.Exec(ctx, `update supply_chain_policies set active=false where active`); err != nil { + return policy.Policy{}, err + } + } + item.Version, item.Active = version, activate + if err := tx.QueryRow(ctx, `insert into supply_chain_policies (name, version, kind, rules, unknown_handling, description, created_by, active) values ($1, $2, $3, $4, $5, $6, $7, $8) returning id`, + item.Name, version, item.Kind, rules, string(item.UnknownHandling), item.Description, item.CreatedBy, activate).Scan(&item.ID); err != nil { + return policy.Policy{}, err + } + return item, tx.Commit(ctx) +} + +const policyColumns = `id, name, version, kind, rules, unknown_handling, description, created_by, active` + +func scanPolicy(row interface{ Scan(...any) error }) (policy.Policy, error) { + var item policy.Policy + var rules []byte + var unknown string + if err := row.Scan(&item.ID, &item.Name, &item.Version, &item.Kind, &rules, &unknown, &item.Description, &item.CreatedBy, &item.Active); err != nil { + return policy.Policy{}, err + } + item.UnknownHandling = policy.Verdict(unknown) + if err := json.Unmarshal(rules, &item.Rules); err != nil { + return policy.Policy{}, err + } + return item, nil +} + +// ActivePolicy returns the active policy or policy.ErrNoPolicy. +func (s *Store) ActivePolicy(ctx context.Context) (policy.Policy, error) { + item, err := scanPolicy(s.pool.QueryRow(ctx, `select `+policyColumns+` from supply_chain_policies where active`)) + if errors.Is(err, pgx.ErrNoRows) { + return policy.Policy{}, policy.ErrNoPolicy + } + return item, err +} + +func (s *Store) Policy(ctx context.Context, id int64) (policy.Policy, error) { + return scanPolicy(s.pool.QueryRow(ctx, `select `+policyColumns+` from supply_chain_policies where id=$1`, id)) +} + +func (s *Store) Policies(ctx context.Context, limit int) ([]policy.Policy, error) { + rows, err := s.pool.Query(ctx, `select `+policyColumns+` from supply_chain_policies order by id desc limit $1`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + policies := []policy.Policy{} + for rows.Next() { + item, err := scanPolicy(rows) + if err != nil { + return nil, err + } + policies = append(policies, item) + } + return policies, rows.Err() +} + +// UpsertPolicyResult records a current evaluation for a component, retiring +// the previous current row rather than editing it. +func (s *Store) UpsertPolicyResult(ctx context.Context, result review.PolicyResult) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + if _, err := tx.Exec(ctx, `update supply_chain_policy_results set current=false where component_id=$1 and current`, result.ComponentID); err != nil { + return err + } + if _, err := tx.Exec(ctx, `insert into supply_chain_policy_results (component_id, snapshot_id, policy_id, evidence_fingerprint, verdict, explanation, evaluated_at, current) values ($1, $2, $3, $4, $5, $6, $7, true)`, + result.ComponentID, result.SnapshotID, result.PolicyID, result.EvidenceFingerprint, string(result.Verdict), result.Explanation, result.EvaluatedAt.UTC()); err != nil { + return err + } + return tx.Commit(ctx) +} + +// PolicyResults returns current results for a snapshot's components. +func (s *Store) PolicyResults(ctx context.Context, snapshotID int64, componentIDs []int64) (map[int64]review.PolicyResult, error) { + rows, err := s.pool.Query(ctx, `select component_id, snapshot_id, policy_id, evidence_fingerprint, verdict, explanation, evaluated_at from supply_chain_policy_results + where snapshot_id=$1 and component_id=any($2) and current`, snapshotID, componentIDs) + if err != nil { + return nil, err + } + defer rows.Close() + results := map[int64]review.PolicyResult{} + for rows.Next() { + var result review.PolicyResult + var verdict string + if err := rows.Scan(&result.ComponentID, &result.SnapshotID, &result.PolicyID, &result.EvidenceFingerprint, &verdict, &result.Explanation, &result.EvaluatedAt); err != nil { + return nil, err + } + result.Verdict = policy.Verdict(verdict) + results[result.ComponentID] = result + } + return results, rows.Err() +} + +// PolicyResultHistory lists every evaluation of one component, newest first. +func (s *Store) PolicyResultHistory(ctx context.Context, componentID int64, limit int) ([]review.PolicyResult, error) { + rows, err := s.pool.Query(ctx, `select component_id, snapshot_id, policy_id, evidence_fingerprint, verdict, explanation, evaluated_at from supply_chain_policy_results + where component_id=$1 order by id desc limit $2`, componentID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + results := []review.PolicyResult{} + for rows.Next() { + var result review.PolicyResult + var verdict string + if err := rows.Scan(&result.ComponentID, &result.SnapshotID, &result.PolicyID, &result.EvidenceFingerprint, &verdict, &result.Explanation, &result.EvaluatedAt); err != nil { + return nil, err + } + result.Verdict = policy.Verdict(verdict) + results = append(results, result) + } + return results, rows.Err() +} + +// ComponentsForPolicyEvaluation lists latest-stream components (with their +// assessments) for the authorized repositories that lack a current result +// for the given policy or whose result fingerprint differs from the +// assessment fingerprint. Bounded. +func (s *Store) ComponentsNeedingEvaluation(ctx context.Context, policyID int64, limit int) ([]review.EvaluationTarget, error) { + rows, err := s.pool.Query(ctx, `select c.id, c.snapshot_id, a.status, a.normalized_expression, a.evidence_fingerprint + from supply_chain_streams st + join supply_chain_components c on c.snapshot_id=st.latest_snapshot_id + left join supply_chain_component_assessments a on a.component_id=c.id + left join supply_chain_policy_results r on r.component_id=c.id and r.current + where r.id is null or r.policy_id<>$1 or coalesce(r.evidence_fingerprint, '') <> coalesce(a.evidence_fingerprint, '') + order by c.id limit $2`, policyID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + targets := []review.EvaluationTarget{} + for rows.Next() { + var target review.EvaluationTarget + var status, expression *string + if err := rows.Scan(&target.ComponentID, &target.SnapshotID, &status, &expression, &target.EvidenceFingerprint); err != nil { + return nil, err + } + if status != nil { + target.AssessmentStatus = license.AssessmentStatus(*status) + } + if expression != nil { + target.Expression = *expression + } + targets = append(targets, target) + } + return targets, rows.Err() +} + +// ReviewAllowed reports whether a subject holds a review grant for the repository. +func (s *Store) ReviewAllowed(ctx context.Context, repositoryID int64, subject string) (bool, error) { + var allowed bool + err := s.pool.QueryRow(ctx, `select exists(select 1 from supply_chain_review_grants where repository_id=$1 and subject=$2)`, repositoryID, subject).Scan(&allowed) + return allowed, err +} + +func (s *Store) SetReviewGrant(ctx context.Context, repositoryID int64, subject, grantedBy string, allow bool) error { + if !allow { + _, err := s.pool.Exec(ctx, `delete from supply_chain_review_grants where repository_id=$1 and subject=$2`, repositoryID, subject) + return err + } + _, err := s.pool.Exec(ctx, `insert into supply_chain_review_grants (repository_id, subject, granted_by) values ($1, $2, $3) on conflict do nothing`, repositoryID, subject, grantedBy) + return err +} + +// RecordConclusion stores a human license conclusion as evidence plus its +// linkage, superseding the previous conclusion for the coordinates. +func (s *Store) RecordConclusion(ctx context.Context, conclusion review.Conclusion, evidence license.Evidence) (review.Conclusion, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return review.Conclusion{}, err + } + defer tx.Rollback(ctx) + var tree []byte + if evidence.ExpressionTree != nil { + if tree, err = json.Marshal(evidence.ExpressionTree); err != nil { + return review.Conclusion{}, err + } + } + detail, err := json.Marshal(nonNilAny(evidence.Detail)) + if err != nil { + return review.Conclusion{}, err + } + unknown := evidence.UnknownTerms + if unknown == nil { + unknown = []string{} + } + var evidenceID int64 + if err := tx.QueryRow(ctx, `insert into supply_chain_license_evidence (source, route, ecosystem, namespace, name, version, raw_value, raw_kind, parse_status, normalized_expression, expression_tree, unknown_terms, + detail, resolver_version, license_list_version, fetched_at, outcome, message) + values ('human', '', $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, 'resolved', $15) returning id`, + conclusion.Coordinates.Ecosystem, conclusion.Coordinates.Namespace, conclusion.Coordinates.Name, conclusion.Coordinates.Version, evidence.RawValue, string(evidence.RawKind), string(evidence.ParseStatus), + evidence.NormalizedExpression, nullableJSON(tree), unknown, detail, evidence.ResolverVersion, evidence.LicenseListVersion, evidence.FetchedAt.UTC(), evidence.Message).Scan(&evidenceID); err != nil { + return review.Conclusion{}, err + } + conclusion.EvidenceID = evidenceID + if err := tx.QueryRow(ctx, `insert into supply_chain_conclusions (ecosystem, namespace, name, version, evidence_id, evidence_fingerprint, reviewer, reason) values ($1, $2, $3, $4, $5, $6, $7, $8) returning id, created_at`, + conclusion.Coordinates.Ecosystem, conclusion.Coordinates.Namespace, conclusion.Coordinates.Name, conclusion.Coordinates.Version, evidenceID, conclusion.BasisFingerprint, conclusion.Reviewer, conclusion.Reason).Scan(&conclusion.ID, &conclusion.CreatedAt); err != nil { + return review.Conclusion{}, err + } + if _, err := tx.Exec(ctx, `update supply_chain_conclusions set superseded_by=$1 where ecosystem=$2 and namespace=$3 and name=$4 and version=$5 and id<>$1 and superseded_by is null`, + conclusion.ID, conclusion.Coordinates.Ecosystem, conclusion.Coordinates.Namespace, conclusion.Coordinates.Name, conclusion.Coordinates.Version); err != nil { + return review.Conclusion{}, err + } + return conclusion, tx.Commit(ctx) +} + +// RecordDecision stores a scoped decision and supersedes the previous +// current decision for the same scope. +func (s *Store) RecordDecision(ctx context.Context, decision review.Decision) (review.Decision, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return review.Decision{}, err + } + defer tx.Rollback(ctx) + if err := tx.QueryRow(ctx, `insert into supply_chain_decisions (repository_id, ecosystem, namespace, name, version, kind, policy_id, policy_verdict, evidence_fingerprint, reviewer, reason, usage_context, expires_at) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) returning id, created_at`, + decision.RepositoryID, decision.Coordinates.Ecosystem, decision.Coordinates.Namespace, decision.Coordinates.Name, decision.Coordinates.Version, string(decision.Kind), decision.PolicyID, string(decision.PolicyVerdict), + decision.EvidenceFingerprint, decision.Reviewer, decision.Reason, decision.UsageContext, decision.ExpiresAt).Scan(&decision.ID, &decision.CreatedAt); err != nil { + return review.Decision{}, err + } + if _, err := tx.Exec(ctx, `update supply_chain_decisions set superseded_by=$1 where repository_id=$2 and ecosystem=$3 and namespace=$4 and name=$5 and version=$6 and id<>$1 and superseded_by is null`, + decision.ID, decision.RepositoryID, decision.Coordinates.Ecosystem, decision.Coordinates.Namespace, decision.Coordinates.Name, decision.Coordinates.Version); err != nil { + return review.Decision{}, err + } + return decision, tx.Commit(ctx) +} + +const decisionColumns = `id, repository_id, ecosystem, namespace, name, version, kind, policy_id, policy_verdict, evidence_fingerprint, reviewer, reason, usage_context, expires_at, created_at, superseded_by` + +func scanDecision(row interface{ Scan(...any) error }) (review.Decision, error) { + var decision review.Decision + var kind, verdict string + if err := row.Scan(&decision.ID, &decision.RepositoryID, &decision.Coordinates.Ecosystem, &decision.Coordinates.Namespace, &decision.Coordinates.Name, &decision.Coordinates.Version, &kind, &decision.PolicyID, &verdict, + &decision.EvidenceFingerprint, &decision.Reviewer, &decision.Reason, &decision.UsageContext, &decision.ExpiresAt, &decision.CreatedAt, &decision.SupersededBy); err != nil { + return review.Decision{}, err + } + decision.Kind, decision.PolicyVerdict = review.DecisionKind(kind), policy.Verdict(verdict) + return decision, nil +} + +// CurrentDecision returns the non-superseded decision for a scope, if any. +func (s *Store) CurrentDecision(ctx context.Context, repositoryID int64, coordinates license.Coordinates) (review.Decision, bool, error) { + decision, err := scanDecision(s.pool.QueryRow(ctx, `select `+decisionColumns+` from supply_chain_decisions where repository_id=$1 and ecosystem=$2 and namespace=$3 and name=$4 and version=$5 and superseded_by is null order by id desc limit 1`, + repositoryID, coordinates.Ecosystem, coordinates.Namespace, coordinates.Name, coordinates.Version)) + if errors.Is(err, pgx.ErrNoRows) { + return review.Decision{}, false, nil + } + return decision, err == nil, err +} + +// DecisionHistory lists every decision for a scope, newest first. +func (s *Store) DecisionHistory(ctx context.Context, repositoryID int64, coordinates license.Coordinates, limit int) ([]review.Decision, error) { + rows, err := s.pool.Query(ctx, `select `+decisionColumns+` from supply_chain_decisions where repository_id=$1 and ecosystem=$2 and namespace=$3 and name=$4 and version=$5 order by id desc limit $6`, + repositoryID, coordinates.Ecosystem, coordinates.Namespace, coordinates.Name, coordinates.Version, limit) + if err != nil { + return nil, err + } + defer rows.Close() + decisions := []review.Decision{} + for rows.Next() { + decision, err := scanDecision(rows) + if err != nil { + return nil, err + } + decisions = append(decisions, decision) + } + return decisions, rows.Err() +} + +// ConclusionHistory lists conclusions for coordinates, newest first. +func (s *Store) ConclusionHistory(ctx context.Context, coordinates license.Coordinates, limit int) ([]review.Conclusion, error) { + rows, err := s.pool.Query(ctx, `select id, ecosystem, namespace, name, version, evidence_id, evidence_fingerprint, reviewer, reason, created_at, superseded_by from supply_chain_conclusions + where ecosystem=$1 and namespace=$2 and name=$3 and version=$4 order by id desc limit $5`, coordinates.Ecosystem, coordinates.Namespace, coordinates.Name, coordinates.Version, limit) + if err != nil { + return nil, err + } + defer rows.Close() + conclusions := []review.Conclusion{} + for rows.Next() { + var conclusion review.Conclusion + if err := rows.Scan(&conclusion.ID, &conclusion.Coordinates.Ecosystem, &conclusion.Coordinates.Namespace, &conclusion.Coordinates.Name, &conclusion.Coordinates.Version, &conclusion.EvidenceID, &conclusion.BasisFingerprint, &conclusion.Reviewer, &conclusion.Reason, &conclusion.CreatedAt, &conclusion.SupersededBy); err != nil { + return nil, err + } + conclusions = append(conclusions, conclusion) + } + return conclusions, rows.Err() +} + +// ReviewQueue lists latest-stream occurrences in the authorized repositories +// whose current policy verdict is not approved and that have no current, +// unexpired decision, ordered by verdict severity then component. Bounded. +func (s *Store) ReviewQueue(ctx context.Context, repositoryIDs []int64, streamKey string, now time.Time, afterID int64, limit int) ([]review.QueueItem, error) { + if len(repositoryIDs) == 0 { + return []review.QueueItem{}, nil + } + rows, err := s.pool.Query(ctx, `select c.id, c.snapshot_id, r.github_id, r.owner || '/' || r.name, c.element_id, c.name, coalesce(c.version, ''), coalesce(c.purl, ''), coalesce(c.ecosystem, ''), coalesce(c.purl_namespace, ''), coalesce(c.purl_name, ''), coalesce(c.purl_version, c.version, ''), + coalesce(a.status, ''), coalesce(a.normalized_expression, ''), a.evidence_fingerprint, coalesce(pr.verdict, ''), coalesce(pr.explanation, ''), pr.policy_id, + d.id, d.kind, d.expires_at, d.evidence_fingerprint + from supply_chain_streams st + join supply_chain_snapshots snap on snap.id=st.latest_snapshot_id + join repositories r on r.id=st.repository_id + join supply_chain_components c on c.snapshot_id=st.latest_snapshot_id + left join supply_chain_component_assessments a on a.component_id=c.id + left join supply_chain_policy_results pr on pr.component_id=c.id and pr.current + left join lateral ( + select id, kind, expires_at, evidence_fingerprint from supply_chain_decisions dd + where dd.repository_id=st.repository_id and dd.ecosystem=coalesce(c.ecosystem, '') and dd.namespace=coalesce(c.purl_namespace, '') and dd.name=coalesce(c.purl_name, '') and dd.version=coalesce(c.purl_version, c.version, '') and dd.superseded_by is null + order by dd.id desc limit 1 + ) d on true + where st.repository_id=any($1) and st.stream_key=$2 and c.id>$3 + and coalesce(pr.verdict, 'unknown') <> 'approved' + and (d.id is null or (d.expires_at is not null and d.expires_at <= $4) or (a.evidence_fingerprint is not null and d.evidence_fingerprint <> a.evidence_fingerprint)) + order by c.id limit $5`, repositoryIDs, streamKey, afterID, now, limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []review.QueueItem{} + for rows.Next() { + var item review.QueueItem + var status, expression, verdict, explanation string + var decisionID *int64 + var decisionKind *string + var decisionExpires *time.Time + var decisionFingerprint []byte + if err := rows.Scan(&item.ComponentID, &item.SnapshotID, &item.RepositoryGitHubID, &item.Repository, &item.ElementID, &item.Name, &item.Version, &item.PURL, &item.Coordinates.Ecosystem, &item.Coordinates.Namespace, + &item.Coordinates.Name, &item.Coordinates.Version, &status, &expression, &item.EvidenceFingerprint, &verdict, &explanation, &item.PolicyID, &decisionID, &decisionKind, &decisionExpires, &decisionFingerprint); err != nil { + return nil, err + } + item.AssessmentStatus, item.Expression, item.Verdict, item.Explanation = license.AssessmentStatus(status), expression, policy.Verdict(verdict), explanation + if decisionID != nil { + item.StaleDecisionID = decisionID + switch { + case decisionExpires != nil && !decisionExpires.After(now): + item.Reason = "exception expired" + case decisionFingerprint != nil && item.EvidenceFingerprint != nil && string(decisionFingerprint) != string(item.EvidenceFingerprint): + item.Reason = "evidence changed since the decision" + } + } else if item.Verdict == "" { + item.Reason = "not yet evaluated" + } else { + item.Reason = "no decision recorded" + } + items = append(items, item) + } + return items, rows.Err() +} + +// RecordReviewEvent appends to the audit trail. +func (s *Store) RecordReviewEvent(ctx context.Context, kind, actor string, repositoryID *int64, target string, detail map[string]any) error { + data, err := json.Marshal(nonNilAny(detail)) + if err != nil { + return err + } + _, err = s.pool.Exec(ctx, `insert into supply_chain_review_events (kind, actor, repository_id, target, detail) values ($1, $2, $3, $4, $5)`, kind, actor, repositoryID, target, data) + return err +} + +// ReviewEvents lists audit events, newest first, optionally for one repository. +func (s *Store) ReviewEvents(ctx context.Context, repositoryIDs []int64, limit int) ([]review.Event, error) { + rows, err := s.pool.Query(ctx, `select id, kind, actor, repository_id, target, detail, created_at from supply_chain_review_events + where repository_id is null or repository_id=any($1) order by id desc limit $2`, repositoryIDs, limit) + if err != nil { + return nil, err + } + defer rows.Close() + events := []review.Event{} + for rows.Next() { + var event review.Event + var detail []byte + if err := rows.Scan(&event.ID, &event.Kind, &event.Actor, &event.RepositoryID, &event.Target, &detail, &event.CreatedAt); err != nil { + return nil, err + } + if err := json.Unmarshal(detail, &event.Detail); err != nil { + return nil, err + } + events = append(events, event) + } + return events, rows.Err() +} + +// SnapshotRepositoryID maps a snapshot to its repository row. +func (s *Store) SnapshotRepositoryID(ctx context.Context, snapshotID int64) (int64, error) { + var repositoryID int64 + err := s.pool.QueryRow(ctx, `select repository_id from supply_chain_snapshots where id=$1`, snapshotID).Scan(&repositoryID) + return repositoryID, err +} diff --git a/internal/supplychain/license/assess.go b/internal/supplychain/license/assess.go index 5eb32aa0..d97a4dcf 100644 --- a/internal/supplychain/license/assess.go +++ b/internal/supplychain/license/assess.go @@ -141,3 +141,48 @@ func describe(items []candidate) string { } return strings.Join(parts, " | ") } + +// AssessWithHuman is Assess with human conclusions given precedence: the +// newest 'human' evidence, when it parsed, becomes the assessment's basis and +// automated evidence that disagrees is reported in the conflict detail +// without changing the status. The fingerprint still covers every row, so a +// later automated change is detectable and can require re-review. +func AssessWithHuman(componentID, snapshotID int64, declaredRaw, concludedRaw *string, registry []Evidence, now time.Time) Assessment { + var human *Evidence + for index := range registry { + if registry[index].Source == SourceHuman && registry[index].Outcome == OutcomeResolved && (human == nil || registry[index].ID > human.ID) { + human = ®istry[index] + } + } + if human == nil { + return Assess(componentID, snapshotID, declaredRaw, concludedRaw, registry, now) + } + automated := make([]Evidence, 0, len(registry)) + for _, evidence := range registry { + if evidence.Source != SourceHuman { + automated = append(automated, evidence) + } + } + base := Assess(componentID, snapshotID, declaredRaw, concludedRaw, registry, now) + assessment := Assessment{ComponentID: componentID, SnapshotID: snapshotID, AssessedAt: now.UTC(), EvidenceIDs: base.EvidenceIDs, EvidenceFingerprint: base.EvidenceFingerprint} + switch human.ParseStatus { + case spdxexpr.StatusParsed, spdxexpr.StatusUnknownTerms: + assessment.Status = AssessmentResolved + assessment.NormalizedExpression = human.NormalizedExpression + case spdxexpr.StatusNone, spdxexpr.StatusUnlicensed: + assessment.Status = AssessmentUnlicensed + default: + return base + } + without := Assess(componentID, snapshotID, declaredRaw, concludedRaw, automated, now) + if without.Status != AssessmentUnknown && (without.NormalizedExpression != assessment.NormalizedExpression || without.Status == AssessmentConflict) { + detail := "human conclusion (" + human.NormalizedExpression + ") overrides automated evidence" + if without.ConflictDetail != "" { + detail += ": " + without.ConflictDetail + } else if without.NormalizedExpression != "" { + detail += ": " + without.NormalizedExpression + } + assessment.ConflictDetail = detail + } + return assessment +} diff --git a/internal/supplychain/policy/policy.go b/internal/supplychain/policy/policy.go new file mode 100644 index 00000000..158034fb --- /dev/null +++ b/internal/supplychain/policy/policy.go @@ -0,0 +1,275 @@ +// Package policy evaluates SPDX license expression trees against a +// versioned organization policy. It walks the tree: AND requires every +// operand to be acceptable, OR is acceptable when any branch is acceptable +// (and reports which), WITH is evaluated as the license+exception pair. +// Unknown terms and unparseable evidence never become approved. It ships no +// company legal policy; the embedded policy is an example fixture. +package policy + +import ( + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + + "github.com/balcsida/graphnest/internal/supplychain/spdxexpr" +) + +// Verdict is the outcome of evaluating one expression against a policy. +type Verdict string + +const ( + VerdictApproved Verdict = "approved" + VerdictProhibited Verdict = "prohibited" + VerdictReviewRequired Verdict = "review_required" + VerdictUnknown Verdict = "unknown" +) + +// Rules is the JSON policy body. Identifiers are SPDX license IDs in list +// casing; "id WITH exception" keys match WITH nodes exactly; a "*" key in a +// class applies to any LicenseRef-/DocumentRef- reference. Anything not +// listed follows UnknownHandling. +type Rules struct { + Approved []string `json:"approved"` + ReviewRequired []string `json:"review_required"` + Prohibited []string `json:"prohibited"` + // AllowOrLater: when true, "GPL-2.0-or-later"/"GPL-2.0+" is classified by + // the base identifier when the exact or-later form is not listed. + AllowOrLater bool `json:"allow_or_later"` +} + +// Policy is one immutable version. +type Policy struct { + ID int64 + Name string + Version int + Kind string + Rules Rules + UnknownHandling Verdict + Description string + CreatedBy string + Active bool +} + +// Result explains a verdict. Branches records, for OR nodes, which branch +// would be acceptable; a recorded choice is a separate decision, not part of +// evaluation. +type Result struct { + Verdict Verdict + Explanation string + // AcceptableBranches lists the OR alternatives that evaluate as approved, + // in written order, when the top-level verdict depends on a choice. + AcceptableBranches []string + // Terms lists each leaf with its classification, for display. + Terms []TermResult +} + +type TermResult struct { + Term string `json:"term"` + Verdict Verdict `json:"verdict"` +} + +// ParseRules decodes and validates a policy body: every identifier must be +// an SPDX list ID, "id WITH exception", a LicenseRef-/DocumentRef- pattern, +// or "*"; no identifier may appear in two classes. +func ParseRules(data []byte) (Rules, error) { + var rules Rules + decoder := json.NewDecoder(strings.NewReader(string(data))) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&rules); err != nil { + return Rules{}, fmt.Errorf("invalid policy rules: %w", err) + } + seen := map[string]string{} + for class, terms := range map[string][]string{"approved": rules.Approved, "review_required": rules.ReviewRequired, "prohibited": rules.Prohibited} { + for _, term := range terms { + canonical, err := canonicalTerm(term) + if err != nil { + return Rules{}, fmt.Errorf("%s: %w", class, err) + } + if previous, duplicate := seen[canonical]; duplicate && previous != class { + return Rules{}, fmt.Errorf("%q is listed as both %s and %s", term, previous, class) + } + seen[canonical] = class + } + } + return rules, nil +} + +func canonicalTerm(term string) (string, error) { + term = strings.TrimSpace(term) + if term == "*" { + return "*", nil + } + lower := strings.ToLower(term) + if strings.HasPrefix(lower, "licenseref-") || strings.HasPrefix(lower, "documentref-") { + return term, nil + } + parsed := spdxexpr.Parse(term) + if parsed.Status != spdxexpr.StatusParsed || parsed.Expression == nil { + return "", fmt.Errorf("%q is not a known SPDX license, exception pair, or reference", term) + } + if parsed.Expression.Kind != spdxexpr.KindLicense && parsed.Expression.Kind != spdxexpr.KindWith { + return "", fmt.Errorf("%q must be a single license or license WITH exception, not a compound expression", term) + } + return parsed.Normalized, nil +} + +// Evaluate classifies a parsed expression against the policy. +func (policy Policy) Evaluate(expression *spdxexpr.Node) Result { + if expression == nil { + return Result{Verdict: policy.UnknownHandling, Explanation: "no license expression is available; " + string(policy.UnknownHandling) + " by policy for unknown licensing"} + } + index := policy.index() + result := Result{} + verdict := policy.evaluate(expression, index, &result) + result.Verdict = verdict + result.Explanation = policy.explain(expression, verdict, result) + return result +} + +type ruleIndex struct { + byTerm map[string]Verdict + wildcard Verdict + hasWild bool +} + +func (policy Policy) index() ruleIndex { + index := ruleIndex{byTerm: map[string]Verdict{}} + add := func(terms []string, verdict Verdict) { + for _, term := range terms { + canonical, err := canonicalTerm(term) + if err != nil { + continue + } + if canonical == "*" { + index.wildcard, index.hasWild = verdict, true + continue + } + index.byTerm[strings.ToLower(canonical)] = verdict + } + } + add(policy.Rules.Prohibited, VerdictProhibited) + add(policy.Rules.ReviewRequired, VerdictReviewRequired) + add(policy.Rules.Approved, VerdictApproved) + return index +} + +func (policy Policy) classifyLeaf(node *spdxexpr.Node, index ruleIndex) Verdict { + term := strings.ToLower(node.String()) + if verdict, ok := index.byTerm[term]; ok { + return verdict + } + switch node.Kind { + case spdxexpr.KindReference: + if index.hasWild { + return index.wildcard + } + return policy.UnknownHandling + case spdxexpr.KindLicense: + if !node.Known { + return policy.UnknownHandling + } + if node.OrLater && policy.Rules.AllowOrLater { + if verdict, ok := index.byTerm[strings.ToLower(node.ID)]; ok { + return verdict + } + } + // "-or-later" identifiers may be listed by their "-only" base when allowed. + if policy.Rules.AllowOrLater && strings.HasSuffix(node.ID, "-or-later") { + if verdict, ok := index.byTerm[strings.ToLower(strings.TrimSuffix(node.ID, "-or-later")+"-only")]; ok { + return verdict + } + } + return policy.UnknownHandling + case spdxexpr.KindWith: + // An unlisted WITH pair is never approved by its base license alone: + // the exception changes the terms, so it needs its own listing. + if !node.Known { + return policy.UnknownHandling + } + return VerdictReviewRequired + } + return policy.UnknownHandling +} + +func (policy Policy) evaluate(node *spdxexpr.Node, index ruleIndex, result *Result) Verdict { + switch node.Kind { + case spdxexpr.KindAnd: + worst := VerdictApproved + for _, child := range node.Children { + worst = worse(worst, policy.evaluate(child, index, result)) + } + return worst + case spdxexpr.KindOr: + best := VerdictProhibited + first := true + for _, child := range node.Children { + verdict := policy.evaluate(child, index, result) + if verdict == VerdictApproved { + result.AcceptableBranches = append(result.AcceptableBranches, child.String()) + } + if first { + best, first = verdict, false + continue + } + best = better(best, verdict) + } + return best + default: + verdict := policy.classifyLeaf(node, index) + result.Terms = append(result.Terms, TermResult{Term: node.String(), Verdict: verdict}) + return verdict + } +} + +var severity = map[Verdict]int{VerdictApproved: 0, VerdictReviewRequired: 1, VerdictUnknown: 2, VerdictProhibited: 3} + +func worse(left, right Verdict) Verdict { + if severity[right] > severity[left] { + return right + } + return left +} + +func better(left, right Verdict) Verdict { + if severity[right] < severity[left] { + return right + } + return left +} + +func (policy Policy) explain(expression *spdxexpr.Node, verdict Verdict, result Result) string { + var parts []string + for _, term := range result.Terms { + parts = append(parts, term.Term+": "+string(term.Verdict)) + } + sort.Strings(parts) + explanation := fmt.Sprintf("%s under %s v%d; terms: %s", verdict, policy.Name, policy.Version, strings.Join(parts, ", ")) + if expression.Kind == spdxexpr.KindOr && len(result.AcceptableBranches) > 0 && len(result.AcceptableBranches) < len(expression.Children) { + explanation += "; acceptable only by choosing " + strings.Join(result.AcceptableBranches, " or ") + " (a recorded choice is a separate decision)" + } + return explanation +} + +// ExampleRules is a clearly labeled fixture, not legal advice or a company +// policy. Operators author their own organization policy. +const ExampleRules = `{ + "approved": ["MIT", "ISC", "BSD-2-Clause", "BSD-3-Clause", "Apache-2.0", "0BSD", "Unlicense", "CC0-1.0", "Zlib", "GPL-2.0-only WITH Classpath-exception-2.0"], + "review_required": ["LGPL-2.1-only", "LGPL-2.1-or-later", "LGPL-3.0-only", "LGPL-3.0-or-later", "MPL-2.0", "EPL-1.0", "EPL-2.0", "CDDL-1.0", "*"], + "prohibited": ["GPL-2.0-only", "GPL-2.0-or-later", "GPL-3.0-only", "GPL-3.0-or-later", "AGPL-3.0-only", "AGPL-3.0-or-later", "SSPL-1.0", "BUSL-1.1"], + "allow_or_later": false +}` + +// Example returns the example policy (version 1, kind "example"). +func Example() Policy { + rules, err := ParseRules([]byte(ExampleRules)) + if err != nil { + panic(err) + } + return Policy{Name: "graphnest-example", Version: 1, Kind: "example", Rules: rules, UnknownHandling: VerdictReviewRequired, + Description: "EXAMPLE FIXTURE for demonstration and tests. Not legal advice and not an organization policy; replace with your own before relying on verdicts."} +} + +// ErrNoPolicy: no active organization policy exists. +var ErrNoPolicy = errors.New("no active policy") diff --git a/internal/supplychain/policy/policy_test.go b/internal/supplychain/policy/policy_test.go new file mode 100644 index 00000000..408f476c --- /dev/null +++ b/internal/supplychain/policy/policy_test.go @@ -0,0 +1,144 @@ +package policy + +import ( + "strings" + "testing" + + "github.com/balcsida/graphnest/internal/supplychain/spdxexpr" +) + +func evaluate(t *testing.T, policy Policy, expression string) Result { + t.Helper() + parsed := spdxexpr.Parse(expression) + if parsed.Status != spdxexpr.StatusParsed && parsed.Status != spdxexpr.StatusUnknownTerms { + return policy.Evaluate(nil) + } + return policy.Evaluate(parsed.Expression) +} + +func TestExamplePolicyTruthTable(t *testing.T) { + policy := Example() + cases := map[string]struct { + verdict Verdict + branches []string + }{ + // leaves + "MIT": {verdict: VerdictApproved}, + "GPL-3.0-only": {verdict: VerdictProhibited}, + "LGPL-2.1-only": {verdict: VerdictReviewRequired}, + "Custom-Corp-1.0": {verdict: VerdictReviewRequired}, // unknown term -> unknown handling + "LicenseRef-Acme-Internal": {verdict: VerdictReviewRequired}, // "*" wildcard + // AND: worst operand wins + "MIT AND Apache-2.0": {verdict: VerdictApproved}, + "MIT AND GPL-3.0-only": {verdict: VerdictProhibited}, + "MIT AND LGPL-2.1-only": {verdict: VerdictReviewRequired}, + "MIT AND Custom-Corp-1.0": {verdict: VerdictReviewRequired}, + "GPL-3.0-only AND MPL-2.0": {verdict: VerdictProhibited}, + // OR: best branch wins, acceptable branches reported + "MIT OR GPL-3.0-only": {verdict: VerdictApproved, branches: []string{"MIT"}}, + "GPL-3.0-only OR MIT": {verdict: VerdictApproved, branches: []string{"MIT"}}, + "GPL-3.0-only OR LGPL-2.1-only": {verdict: VerdictReviewRequired}, + "GPL-3.0-only OR AGPL-3.0-only": {verdict: VerdictProhibited}, + "MIT OR Apache-2.0": {verdict: VerdictApproved, branches: []string{"MIT", "Apache-2.0"}}, + "Custom-Corp-1.0 OR MIT": {verdict: VerdictApproved, branches: []string{"MIT"}}, + "(MIT AND GPL-3.0-only) OR ISC": {verdict: VerdictApproved, branches: []string{"ISC"}}, + "MIT AND (GPL-3.0-only OR ISC)": {verdict: VerdictApproved, branches: []string{"ISC"}}, + "MIT AND (GPL-3.0-only OR AGPL-3.0-only)": {verdict: VerdictProhibited}, + // WITH: the pair is its own term + "GPL-2.0-only WITH Classpath-exception-2.0": {verdict: VerdictApproved}, + "GPL-2.0-only": {verdict: VerdictProhibited}, + "GPL-3.0-only WITH Classpath-exception-2.0": {verdict: VerdictReviewRequired}, // unlisted pair is not approved by its base + "MIT WITH Made-Up-Exception": {verdict: VerdictReviewRequired}, + // or-later handling off: exact identifiers only + "GPL-2.0-or-later": {verdict: VerdictProhibited}, + "LGPL-2.1+": {verdict: VerdictReviewRequired}, + "Apache-2.0+": {verdict: VerdictReviewRequired}, // "+" form is not listed and allow_or_later is false + } + for expression, want := range cases { + t.Run(expression, func(t *testing.T) { + got := evaluate(t, policy, expression) + if got.Verdict != want.verdict { + t.Fatalf("verdict = %s (%s), want %s", got.Verdict, got.Explanation, want.verdict) + } + if strings.Join(got.AcceptableBranches, ",") != strings.Join(want.branches, ",") { + t.Fatalf("branches = %v, want %v", got.AcceptableBranches, want.branches) + } + if got.Explanation == "" || !strings.Contains(got.Explanation, "graphnest-example v1") { + t.Fatalf("explanation = %q", got.Explanation) + } + }) + } +} + +func TestUnknownHandlingNeverApproves(t *testing.T) { + strict := Example() + strict.UnknownHandling = VerdictProhibited + for _, expression := range []string{"Custom-1.0", "MIT AND Custom-1.0", "NOASSERTION", "", "SEE LICENSE IN LICENSE"} { + got := evaluate(t, strict, expression) + if got.Verdict == VerdictApproved { + t.Fatalf("%q approved: %s", expression, got.Explanation) + } + } + if got := strict.Evaluate(nil); got.Verdict != VerdictProhibited || !strings.Contains(got.Explanation, "no license expression") { + t.Fatalf("nil expression = %+v", got) + } + lenient := Example() + if got := lenient.Evaluate(nil); got.Verdict != VerdictReviewRequired { + t.Fatalf("nil with review_required handling = %+v", got) + } +} + +func TestAllowOrLaterUsesBaseClassification(t *testing.T) { + policy := Example() + policy.Rules.AllowOrLater = true + policy.Rules.Approved = append(policy.Rules.Approved, "Apache-2.0") + if got := evaluate(t, policy, "Apache-2.0+"); got.Verdict != VerdictApproved { + t.Fatalf("Apache-2.0+ = %s", got.Verdict) + } + // GPL-2.0-or-later is explicitly prohibited regardless. + if got := evaluate(t, policy, "GPL-2.0-or-later"); got.Verdict != VerdictProhibited { + t.Fatalf("GPL-2.0-or-later = %s", got.Verdict) + } + // An unlisted or-later maps to its -only base when allowed. + policy.Rules.Prohibited = []string{"GPL-2.0-only"} + if got := evaluate(t, policy, "GPL-2.0-or-later"); got.Verdict != VerdictProhibited { + t.Fatalf("GPL-2.0-or-later via base = %s", got.Verdict) + } +} + +func TestParseRulesValidation(t *testing.T) { + for name, body := range map[string]string{ + "unknown field": `{"approved":["MIT"],"extra":true}`, + "unknown id": `{"approved":["Not-A-License-9"]}`, + "compound": `{"approved":["MIT OR ISC"]}`, + "duplicate": `{"approved":["MIT"],"prohibited":["mit"]}`, + "not json": `{"approved":`, + } { + if _, err := ParseRules([]byte(body)); err == nil { + t.Fatalf("%s accepted", name) + } + } + rules, err := ParseRules([]byte(`{"approved":["mit"],"review_required":["LicenseRef-Acme","*"],"prohibited":["GPL-2.0-only WITH Classpath-exception-2.0"]}`)) + if err != nil || len(rules.Approved) != 1 || len(rules.ReviewRequired) != 2 { + t.Fatalf("rules = %+v %v", rules, err) + } + // Example rules parse and the example is labelled as such. + if Example().Kind != "example" || !strings.Contains(Example().Description, "EXAMPLE FIXTURE") { + t.Fatalf("example = %+v", Example()) + } +} + +func TestDifferentGroupingsGetDifferentVerdicts(t *testing.T) { + policy := Example() + left := evaluate(t, policy, "MIT AND (GPL-3.0-only OR ISC)") + right := evaluate(t, policy, "(MIT AND GPL-3.0-only) OR ISC") + // Both approved via ISC, but the explanations differ and neither flattens. + if left.Verdict != VerdictApproved || right.Verdict != VerdictApproved { + t.Fatalf("%s / %s", left.Verdict, right.Verdict) + } + strictGrouping := evaluate(t, policy, "MIT AND (GPL-3.0-only OR AGPL-3.0-only)") + flat := evaluate(t, policy, "MIT OR GPL-3.0-only OR AGPL-3.0-only") + if strictGrouping.Verdict != VerdictProhibited || flat.Verdict != VerdictApproved { + t.Fatalf("grouping changed nothing: %s vs %s", strictGrouping.Verdict, flat.Verdict) + } +} diff --git a/internal/supplychain/review/review.go b/internal/supplychain/review/review.go new file mode 100644 index 00000000..9533d411 --- /dev/null +++ b/internal/supplychain/review/review.go @@ -0,0 +1,632 @@ +// Package review records human license conclusions, policy evaluation +// results, and scoped usage decisions. The three are separate records: +// a conclusion corrects evidence, an evaluation applies a policy version, +// and a decision approves or rejects usage in one repository (or grants an +// expiring exception). Prior records are superseded, never edited, and every +// decision carries the evidence fingerprint and policy version it was made +// against so a later change is detectable. +package review + +import ( + "context" + "encoding/hex" + "errors" + "strconv" + "strings" + "time" + + "github.com/balcsida/graphnest/internal/authn" + "github.com/balcsida/graphnest/internal/repository" + "github.com/balcsida/graphnest/internal/supplychain/license" + "github.com/balcsida/graphnest/internal/supplychain/policy" + "github.com/balcsida/graphnest/internal/supplychain/spdxexpr" + "github.com/jackc/pgx/v5" +) + +var ( + ErrForbidden = errors.New("forbidden") + ErrInvalidRequest = errors.New("invalid_request") + ErrNotFound = errors.New("not_found") + // ErrStaleBasis: the caller's evidence fingerprint no longer matches the + // current assessment, so the reviewer would decide on changed evidence. + ErrStaleBasis = errors.New("stale_basis") +) + +type DecisionKind string + +const ( + DecisionApprove DecisionKind = "approve" + DecisionReject DecisionKind = "reject" + DecisionException DecisionKind = "exception" +) + +type Conclusion struct { + ID int64 + Coordinates license.Coordinates + EvidenceID int64 + BasisFingerprint []byte + Reviewer string + Reason string + CreatedAt time.Time + SupersededBy *int64 +} + +type PolicyResult struct { + ComponentID int64 + SnapshotID int64 + PolicyID int64 + EvidenceFingerprint []byte + Verdict policy.Verdict + Explanation string + EvaluatedAt time.Time +} + +type EvaluationTarget struct { + ComponentID int64 + SnapshotID int64 + AssessmentStatus license.AssessmentStatus + Expression string + EvidenceFingerprint []byte +} + +type Decision struct { + ID int64 + RepositoryID int64 + Coordinates license.Coordinates + Kind DecisionKind + PolicyID *int64 + PolicyVerdict policy.Verdict + EvidenceFingerprint []byte + Reviewer string + Reason string + UsageContext string + ExpiresAt *time.Time + CreatedAt time.Time + SupersededBy *int64 +} + +type QueueItem struct { + ComponentID int64 + SnapshotID int64 + RepositoryGitHubID int64 + Repository string + ElementID string + Name string + Version string + PURL string + Coordinates license.Coordinates + AssessmentStatus license.AssessmentStatus + Expression string + EvidenceFingerprint []byte + Verdict policy.Verdict + Explanation string + PolicyID *int64 + StaleDecisionID *int64 + Reason string +} + +type Event struct { + ID int64 + Kind string + Actor string + RepositoryID *int64 + Target string + Detail map[string]any + CreatedAt time.Time +} + +// Store is the persistence the review service needs. +type Store interface { + CreatePolicy(context.Context, policy.Policy, bool) (policy.Policy, error) + ActivePolicy(context.Context) (policy.Policy, error) + Policy(context.Context, int64) (policy.Policy, error) + Policies(context.Context, int) ([]policy.Policy, error) + UpsertPolicyResult(context.Context, PolicyResult) error + PolicyResults(context.Context, int64, []int64) (map[int64]PolicyResult, error) + PolicyResultHistory(context.Context, int64, int) ([]PolicyResult, error) + ComponentsNeedingEvaluation(context.Context, int64, int) ([]EvaluationTarget, error) + ReviewAllowed(context.Context, int64, string) (bool, error) + SetReviewGrant(context.Context, int64, string, string, bool) error + RecordConclusion(context.Context, Conclusion, license.Evidence) (Conclusion, error) + ConclusionHistory(context.Context, license.Coordinates, int) ([]Conclusion, error) + RecordDecision(context.Context, Decision) (Decision, error) + CurrentDecision(context.Context, int64, license.Coordinates) (Decision, bool, error) + DecisionHistory(context.Context, int64, license.Coordinates, int) ([]Decision, error) + ReviewQueue(context.Context, []int64, string, time.Time, int64, int) ([]QueueItem, error) + RecordReviewEvent(context.Context, string, string, *int64, string, map[string]any) error + ReviewEvents(context.Context, []int64, int) ([]Event, error) + // Assessment plumbing shared with the license worker. + ComponentsForCoordinates(context.Context, license.Coordinates, int) ([][2]int64, error) + ComponentDeclarations(context.Context, int64) (*string, *string, error) + LatestLicenseEvidence(context.Context, license.Coordinates) ([]license.Evidence, error) + UpsertAssessment(context.Context, license.Assessment) error + SupplyChainAssessments(context.Context, int64, []int64) (map[int64]license.Assessment, error) +} + +// Authorizer resolves repository scope for the live principal. +type Authorizer interface { + AllAuthorizedRepositories(context.Context, authn.Principal) ([]repository.Repository, error) + AuthorizedRepository(context.Context, authn.Principal, int64) (repository.Repository, error) +} + +// Service is the review application layer. +type Service struct { + Store Store + Authorizer Authorizer + MaxResults int + Now func() time.Time +} + +func (service *Service) now() time.Time { + if service.Now != nil { + return service.Now().UTC() + } + return time.Now().UTC() +} + +func (service *Service) maxResults() int { + if service.MaxResults <= 0 || service.MaxResults > 500 { + return 100 + } + return service.MaxResults +} + +// canReview: administrators, or principals with a review grant on the +// repository; reviewers must also be able to read the repository (the +// Authorizer check happens first in every caller). +func (service *Service) canReview(ctx context.Context, principal authn.Principal, repositoryID int64) error { + if principal.Administrator { + return nil + } + allowed, err := service.Store.ReviewAllowed(ctx, repositoryID, principal.Subject) + if err != nil { + return err + } + if !allowed { + return ErrForbidden + } + return nil +} + +func (service *Service) authorizedRepository(ctx context.Context, principal authn.Principal, githubID int64) (repository.Repository, error) { + if githubID < 1 { + return repository.Repository{}, ErrInvalidRequest + } + repo, err := service.Authorizer.AuthorizedRepository(ctx, principal, githubID) + if errors.Is(err, pgx.ErrNoRows) { + return repository.Repository{}, ErrNotFound + } + return repo, err +} + +// CreatePolicy stores a new immutable policy version. Policy administration +// is administrator-only (D5 bootstrap; distinct from the review capability). +func (service *Service) CreatePolicy(ctx context.Context, principal authn.Principal, name, description string, rules []byte, unknownHandling policy.Verdict, activate bool) (policy.Policy, error) { + if !principal.Administrator { + return policy.Policy{}, ErrForbidden + } + name = strings.TrimSpace(name) + if name == "" || len(name) > 128 || len(description) > 4096 || len(rules) > 64<<10 { + return policy.Policy{}, ErrInvalidRequest + } + if unknownHandling != policy.VerdictReviewRequired && unknownHandling != policy.VerdictProhibited { + return policy.Policy{}, ErrInvalidRequest + } + parsed, err := policy.ParseRules(rules) + if err != nil { + return policy.Policy{}, errors.Join(ErrInvalidRequest, err) + } + created, err := service.Store.CreatePolicy(ctx, policy.Policy{Name: name, Kind: "organization", Rules: parsed, UnknownHandling: unknownHandling, Description: description, CreatedBy: principal.Subject}, activate) + if err != nil { + return policy.Policy{}, err + } + _ = service.Store.RecordReviewEvent(ctx, "policy_created", principal.Subject, nil, name+" v"+itoa(created.Version), map[string]any{"policy_id": created.ID, "active": activate, "unknown_handling": string(unknownHandling)}) + return created, nil +} + +// InstallExamplePolicy stores the clearly labelled example fixture without +// activating it, so an operator can inspect it before authoring their own. +func (service *Service) InstallExamplePolicy(ctx context.Context, principal authn.Principal, activate bool) (policy.Policy, error) { + if !principal.Administrator { + return policy.Policy{}, ErrForbidden + } + example := policy.Example() + example.CreatedBy = principal.Subject + created, err := service.Store.CreatePolicy(ctx, example, activate) + if err != nil { + return policy.Policy{}, err + } + _ = service.Store.RecordReviewEvent(ctx, "policy_created", principal.Subject, nil, example.Name+" v"+itoa(created.Version), map[string]any{"policy_id": created.ID, "active": activate, "kind": "example"}) + return created, nil +} + +func (service *Service) Policies(ctx context.Context, principal authn.Principal) ([]policy.Policy, error) { + return service.Store.Policies(ctx, service.maxResults()) +} + +// EvaluatePending applies the active policy to components lacking a current +// result for it (or whose evidence changed). Returns how many were evaluated. +// Historical results stay in place as non-current rows. +func (service *Service) EvaluatePending(ctx context.Context, limit int) (int, error) { + active, err := service.Store.ActivePolicy(ctx) + if errors.Is(err, policy.ErrNoPolicy) { + return 0, nil + } + if err != nil { + return 0, err + } + if limit <= 0 { + limit = 500 + } + targets, err := service.Store.ComponentsNeedingEvaluation(ctx, active.ID, limit) + if err != nil { + return 0, err + } + for _, target := range targets { + result := service.evaluate(active, target) + if err := service.Store.UpsertPolicyResult(ctx, result); err != nil { + return 0, err + } + } + return len(targets), nil +} + +func (service *Service) evaluate(active policy.Policy, target EvaluationTarget) PolicyResult { + result := PolicyResult{ComponentID: target.ComponentID, SnapshotID: target.SnapshotID, PolicyID: active.ID, EvidenceFingerprint: target.EvidenceFingerprint, EvaluatedAt: service.now()} + switch target.AssessmentStatus { + case license.AssessmentResolved, license.AssessmentDeclared: + parsed := spdxexpr.Parse(target.Expression) + evaluation := active.Evaluate(parsed.Expression) + result.Verdict, result.Explanation = evaluation.Verdict, evaluation.Explanation + case license.AssessmentConflict: + result.Verdict = policy.VerdictReviewRequired + result.Explanation = "review_required: evidence sources disagree; resolve the conflict with a human conclusion before applying " + active.Name + case license.AssessmentUnlicensed: + result.Verdict = policy.VerdictProhibited + result.Explanation = "prohibited: the package asserts no license grant (UNLICENSED/NONE)" + default: + evaluation := active.Evaluate(nil) + result.Verdict, result.Explanation = evaluation.Verdict, evaluation.Explanation + } + return result +} + +// Queue lists occurrences needing review within the caller's scope. +func (service *Service) Queue(ctx context.Context, principal authn.Principal, streamKey string, afterID int64, limit int) ([]QueueItem, bool, error) { + if streamKey == "" { + streamKey = "github:source" + } + repositories, err := service.Authorizer.AllAuthorizedRepositories(ctx, principal) + if err != nil { + return nil, false, err + } + ids := make([]int64, 0, len(repositories)) + for _, repo := range repositories { + ids = append(ids, repo.ID) + } + if limit <= 0 || limit > service.maxResults() { + limit = service.maxResults() + } + items, err := service.Store.ReviewQueue(ctx, ids, streamKey, service.now(), afterID, limit+1) + if err != nil { + return nil, false, err + } + truncated := len(items) > limit + if truncated { + items = items[:limit] + } + return items, truncated, nil +} + +// ConcludeRequest is a human license conclusion for exact coordinates. +type ConcludeRequest struct { + RepositoryID int64 // GitHub ID; scopes the permission check and the reassessment + Coordinates license.Coordinates + Expression string + Reason string + BasisFingerprint string // hex of the assessment fingerprint the reviewer saw +} + +// Conclude records a human conclusion as 'human' evidence and rebuilds the +// assessments of every latest-stream occurrence of the coordinates. The +// conclusion must parse as an SPDX expression (or NONE); the basis +// fingerprint must match the current assessment of an occurrence in the +// named repository, or the reviewer would be concluding on changed evidence. +func (service *Service) Conclude(ctx context.Context, principal authn.Principal, request ConcludeRequest) (Conclusion, error) { + repo, err := service.authorizedRepository(ctx, principal, request.RepositoryID) + if err != nil { + return Conclusion{}, err + } + if err := service.canReview(ctx, principal, repo.ID); err != nil { + return Conclusion{}, err + } + reason := strings.TrimSpace(request.Reason) + if reason == "" || len(reason) > 4096 || request.Coordinates.Name == "" || request.Coordinates.Version == "" || request.Coordinates.Ecosystem == "" { + return Conclusion{}, ErrInvalidRequest + } + parsed := spdxexpr.Parse(request.Expression) + if parsed.Status != spdxexpr.StatusParsed && parsed.Status != spdxexpr.StatusNone && parsed.Status != spdxexpr.StatusUnlicensed { + return Conclusion{}, errors.Join(ErrInvalidRequest, errors.New("conclusion must be a valid SPDX expression with known identifiers, NONE, or UNLICENSED")) + } + basis, err := service.currentBasis(ctx, repo.ID, request.Coordinates) + if err != nil { + return Conclusion{}, err + } + if request.BasisFingerprint == "" || request.BasisFingerprint != hex.EncodeToString(basis) { + return Conclusion{}, ErrStaleBasis + } + evidence := license.Evidence{Source: license.SourceHuman, Coordinates: request.Coordinates, FetchedAt: service.now(), Outcome: license.OutcomeResolved, RawValue: strings.TrimSpace(request.Expression), RawKind: license.RawExpression, + ParseStatus: parsed.Status, NormalizedExpression: parsed.Normalized, ExpressionTree: parsed.Expression, ResolverVersion: license.ResolverVersion, LicenseListVersion: spdxexpr.ListVersion, + Detail: map[string]any{"reviewer": principal.Subject, "reason": reason}, Message: "human conclusion by " + principal.Subject} + conclusion, err := service.Store.RecordConclusion(ctx, Conclusion{Coordinates: request.Coordinates, BasisFingerprint: basis, Reviewer: principal.Subject, Reason: reason}, evidence) + if err != nil { + return Conclusion{}, err + } + repoID := repo.ID + _ = service.Store.RecordReviewEvent(ctx, "conclusion_recorded", principal.Subject, &repoID, coordinateLabel(request.Coordinates), map[string]any{"conclusion_id": conclusion.ID, "expression": parsed.Normalized, "basis": request.BasisFingerprint}) + return conclusion, service.reassess(ctx, request.Coordinates) +} + +// reassess rebuilds assessments for every occurrence of the coordinates and +// marks their policy results for re-evaluation (the evaluation loop picks up +// fingerprint changes). +func (service *Service) reassess(ctx context.Context, coordinates license.Coordinates) error { + evidence, err := service.Store.LatestLicenseEvidence(ctx, coordinates) + if err != nil { + return err + } + occurrences, err := service.Store.ComponentsForCoordinates(ctx, coordinates, 10000) + if err != nil { + return err + } + for _, occurrence := range occurrences { + declared, concluded, err := service.Store.ComponentDeclarations(ctx, occurrence[0]) + if err != nil { + return err + } + if err := service.Store.UpsertAssessment(ctx, license.AssessWithHuman(occurrence[0], occurrence[1], declared, concluded, evidence, service.now())); err != nil { + return err + } + } + return nil +} + +// currentBasis returns the assessment fingerprint of the coordinates' latest +// occurrence in the repository (ErrNotFound when none exists). +func (service *Service) currentBasis(ctx context.Context, repositoryID int64, coordinates license.Coordinates) ([]byte, error) { + occurrences, err := service.Store.ComponentsForCoordinates(ctx, coordinates, 10000) + if err != nil { + return nil, err + } + for _, occurrence := range occurrences { + assessments, err := service.Store.SupplyChainAssessments(ctx, occurrence[1], []int64{occurrence[0]}) + if err != nil { + return nil, err + } + assessment, ok := assessments[occurrence[0]] + if !ok { + continue + } + inRepository, err := service.occurrenceInRepository(ctx, occurrence[1], repositoryID) + if err != nil { + return nil, err + } + if inRepository { + return assessment.EvidenceFingerprint, nil + } + } + return nil, ErrNotFound +} + +// SnapshotRepository is optionally implemented by the store to map a +// snapshot to its repository. +type SnapshotRepository interface { + SnapshotRepositoryID(context.Context, int64) (int64, error) +} + +func (service *Service) occurrenceInRepository(ctx context.Context, snapshotID, repositoryID int64) (bool, error) { + lookup, ok := service.Store.(SnapshotRepository) + if !ok { + return true, nil + } + owner, err := lookup.SnapshotRepositoryID(ctx, snapshotID) + if err != nil { + return false, err + } + return owner == repositoryID, nil +} + +// DecideRequest is a scoped usage decision. +type DecideRequest struct { + RepositoryID int64 + Coordinates license.Coordinates + Kind DecisionKind + Reason string + UsageContext string + ExpiresAt *time.Time + BasisFingerprint string +} + +// Decide records an approve/reject/exception for exact coordinates in one +// repository. It requires the current assessment fingerprint (optimistic +// concurrency) and records the active policy version and verdict alongside; +// an exception never erases a prohibited verdict or rewrites the license. +func (service *Service) Decide(ctx context.Context, principal authn.Principal, request DecideRequest) (Decision, error) { + repo, err := service.authorizedRepository(ctx, principal, request.RepositoryID) + if err != nil { + return Decision{}, err + } + if err := service.canReview(ctx, principal, repo.ID); err != nil { + return Decision{}, err + } + reason := strings.TrimSpace(request.Reason) + if reason == "" || len(reason) > 4096 || len(request.UsageContext) > 1024 || request.Coordinates.Name == "" || request.Coordinates.Version == "" || request.Coordinates.Ecosystem == "" { + return Decision{}, ErrInvalidRequest + } + switch request.Kind { + case DecisionApprove, DecisionReject: + if request.ExpiresAt != nil { + return Decision{}, ErrInvalidRequest + } + case DecisionException: + if request.ExpiresAt == nil || !request.ExpiresAt.After(service.now()) || request.ExpiresAt.After(service.now().Add(366*24*time.Hour)) { + return Decision{}, errors.Join(ErrInvalidRequest, errors.New("an exception needs an expiry within one year")) + } + default: + return Decision{}, ErrInvalidRequest + } + basis, err := service.currentBasis(ctx, repo.ID, request.Coordinates) + if err != nil { + return Decision{}, err + } + if request.BasisFingerprint == "" || request.BasisFingerprint != hex.EncodeToString(basis) { + return Decision{}, ErrStaleBasis + } + decision := Decision{RepositoryID: repo.ID, Coordinates: request.Coordinates, Kind: request.Kind, EvidenceFingerprint: basis, Reviewer: principal.Subject, Reason: reason, UsageContext: strings.TrimSpace(request.UsageContext), ExpiresAt: request.ExpiresAt} + if active, err := service.Store.ActivePolicy(ctx); err == nil { + decision.PolicyID = &active.ID + if verdict, ok := service.currentVerdict(ctx, repo.ID, request.Coordinates); ok { + decision.PolicyVerdict = verdict + } + } else if !errors.Is(err, policy.ErrNoPolicy) { + return Decision{}, err + } + recorded, err := service.Store.RecordDecision(ctx, decision) + if err != nil { + return Decision{}, err + } + repoID := repo.ID + detail := map[string]any{"decision_id": recorded.ID, "kind": string(request.Kind), "basis": request.BasisFingerprint, "policy_verdict": string(decision.PolicyVerdict)} + if request.ExpiresAt != nil { + detail["expires_at"] = request.ExpiresAt.UTC().Format(time.RFC3339) + } + _ = service.Store.RecordReviewEvent(ctx, "decision_recorded", principal.Subject, &repoID, coordinateLabel(request.Coordinates), detail) + return recorded, nil +} + +func (service *Service) currentVerdict(ctx context.Context, repositoryID int64, coordinates license.Coordinates) (policy.Verdict, bool) { + occurrences, err := service.Store.ComponentsForCoordinates(ctx, coordinates, 10000) + if err != nil { + return "", false + } + for _, occurrence := range occurrences { + inRepository, err := service.occurrenceInRepository(ctx, occurrence[1], repositoryID) + if err != nil || !inRepository { + continue + } + results, err := service.Store.PolicyResults(ctx, occurrence[1], []int64{occurrence[0]}) + if err != nil { + return "", false + } + if result, ok := results[occurrence[0]]; ok { + return result.Verdict, true + } + } + return "", false +} + +// History returns conclusions, decisions, and policy results for coordinates +// in one authorized repository. +type History struct { + Conclusions []Conclusion + Decisions []Decision + Current *Decision + CurrentStale bool + StaleReason string + PolicyResults []PolicyResult + Basis string +} + +func (service *Service) History(ctx context.Context, principal authn.Principal, githubID int64, coordinates license.Coordinates) (History, error) { + repo, err := service.authorizedRepository(ctx, principal, githubID) + if err != nil { + return History{}, err + } + if coordinates.Name == "" || coordinates.Ecosystem == "" { + return History{}, ErrInvalidRequest + } + history := History{Conclusions: []Conclusion{}, Decisions: []Decision{}, PolicyResults: []PolicyResult{}} + history.Conclusions, err = service.Store.ConclusionHistory(ctx, coordinates, service.maxResults()) + if err != nil { + return History{}, err + } + history.Decisions, err = service.Store.DecisionHistory(ctx, repo.ID, coordinates, service.maxResults()) + if err != nil { + return History{}, err + } + if current, ok, err := service.Store.CurrentDecision(ctx, repo.ID, coordinates); err != nil { + return History{}, err + } else if ok { + history.Current = ¤t + if current.ExpiresAt != nil && !current.ExpiresAt.After(service.now()) { + history.CurrentStale, history.StaleReason = true, "exception expired" + } + } + basis, err := service.currentBasis(ctx, repo.ID, coordinates) + if err == nil { + history.Basis = hex.EncodeToString(basis) + if history.Current != nil && string(history.Current.EvidenceFingerprint) != string(basis) { + history.CurrentStale, history.StaleReason = true, "evidence changed since the decision" + } + occurrences, err := service.Store.ComponentsForCoordinates(ctx, coordinates, 10000) + if err != nil { + return History{}, err + } + for _, occurrence := range occurrences { + if inRepository, err := service.occurrenceInRepository(ctx, occurrence[1], repo.ID); err == nil && inRepository { + history.PolicyResults, err = service.Store.PolicyResultHistory(ctx, occurrence[0], service.maxResults()) + if err != nil { + return History{}, err + } + break + } + } + } else if !errors.Is(err, ErrNotFound) { + return History{}, err + } + return history, nil +} + +// SetReviewGrant is administrator-only; reviewers still need read access to +// the repository, enforced at use. +func (service *Service) SetReviewGrant(ctx context.Context, principal authn.Principal, githubID int64, subject string, allow bool) error { + if !principal.Administrator { + return ErrForbidden + } + if subject == "" || len(subject) > 256 { + return ErrInvalidRequest + } + repo, err := service.authorizedRepository(ctx, principal, githubID) + if err != nil { + return err + } + if err := service.Store.SetReviewGrant(ctx, repo.ID, subject, principal.Subject, allow); err != nil { + return err + } + repoID := repo.ID + return service.Store.RecordReviewEvent(ctx, "review_grant_changed", principal.Subject, &repoID, subject, map[string]any{"allow": allow}) +} + +func (service *Service) Events(ctx context.Context, principal authn.Principal) ([]Event, error) { + repositories, err := service.Authorizer.AllAuthorizedRepositories(ctx, principal) + if err != nil { + return nil, err + } + ids := make([]int64, 0, len(repositories)) + for _, repo := range repositories { + ids = append(ids, repo.ID) + } + return service.Store.ReviewEvents(ctx, ids, service.maxResults()) +} + +func coordinateLabel(coordinates license.Coordinates) string { + label := coordinates.Ecosystem + ":" + coordinates.Name + "@" + coordinates.Version + if coordinates.Namespace != "" { + label = coordinates.Ecosystem + ":" + coordinates.Namespace + "/" + coordinates.Name + "@" + coordinates.Version + } + return label +} + +func itoa(value int) string { + return strconv.Itoa(value) +} diff --git a/pkg/api/supply_chain.go b/pkg/api/supply_chain.go index ebaa4be2..e6b4c867 100644 --- a/pkg/api/supply_chain.go +++ b/pkg/api/supply_chain.go @@ -1,6 +1,9 @@ package api -import "time" +import ( + "encoding/json" + "time" +) // SupplyChainRepositoryStatus describes one repository stream: what GraphNest // currently serves, when it was observed, and how the latest attempt went. @@ -365,3 +368,135 @@ type SupplyChainImportResponse struct { SubjectAssurance string `json:"subject_assurance,omitempty"` Notes []string `json:"notes"` } + +// Review workflow models. Conclusions correct evidence, policy results apply +// a policy version, and decisions approve/reject usage in one repository. +type SupplyChainCoordinates struct { + Ecosystem string `json:"ecosystem"` + Namespace string `json:"namespace,omitempty"` + Name string `json:"name"` + Version string `json:"version"` +} + +type SupplyChainReviewItem struct { + ComponentID int64 `json:"component_id"` + SnapshotID int64 `json:"snapshot_id"` + RepositoryID int64 `json:"repository_id"` + Repository string `json:"repository"` + ElementID string `json:"element_id"` + Name string `json:"name"` + Version string `json:"version"` + PURL string `json:"purl,omitempty"` + Coordinates SupplyChainCoordinates `json:"coordinates"` + Assessment string `json:"assessment"` + Expression string `json:"expression,omitempty"` + Basis string `json:"basis,omitempty"` + Verdict string `json:"verdict"` + Explanation string `json:"explanation"` + PolicyID *int64 `json:"policy_id"` + StaleDecisionID *int64 `json:"stale_decision_id"` + Reason string `json:"reason"` +} + +type SupplyChainReviewQueue struct { + Items []SupplyChainReviewItem `json:"items"` + Truncated bool `json:"truncated"` + NextCursor string `json:"next_cursor,omitempty"` +} + +type SupplyChainConcludeRequest struct { + RepositoryID int64 `json:"repository_id"` + Coordinates SupplyChainCoordinates `json:"coordinates"` + Expression string `json:"expression"` + Reason string `json:"reason"` + BasisFingerprint string `json:"basis"` +} + +type SupplyChainDecideRequest struct { + RepositoryID int64 `json:"repository_id"` + Coordinates SupplyChainCoordinates `json:"coordinates"` + Kind string `json:"kind"` + Reason string `json:"reason"` + UsageContext string `json:"usage_context,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + BasisFingerprint string `json:"basis"` +} + +type SupplyChainConclusion struct { + ID int64 `json:"id"` + Coordinates SupplyChainCoordinates `json:"coordinates"` + EvidenceID int64 `json:"evidence_id"` + Basis string `json:"basis"` + Reviewer string `json:"reviewer"` + Reason string `json:"reason"` + CreatedAt time.Time `json:"created_at"` + SupersededBy *int64 `json:"superseded_by"` +} + +type SupplyChainDecision struct { + ID int64 `json:"id"` + Coordinates SupplyChainCoordinates `json:"coordinates"` + Kind string `json:"kind"` + PolicyID *int64 `json:"policy_id"` + PolicyVerdict string `json:"policy_verdict,omitempty"` + Basis string `json:"basis"` + Reviewer string `json:"reviewer"` + Reason string `json:"reason"` + UsageContext string `json:"usage_context,omitempty"` + ExpiresAt *time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` + SupersededBy *int64 `json:"superseded_by"` +} + +type SupplyChainPolicyResult struct { + PolicyID int64 `json:"policy_id"` + Verdict string `json:"verdict"` + Explanation string `json:"explanation"` + EvaluatedAt time.Time `json:"evaluated_at"` + EvidenceFingerprint string `json:"evidence_fingerprint,omitempty"` +} + +type SupplyChainReviewHistory struct { + Basis string `json:"basis,omitempty"` + Current *SupplyChainDecision `json:"current"` + CurrentStale bool `json:"current_stale"` + StaleReason string `json:"stale_reason,omitempty"` + Conclusions []SupplyChainConclusion `json:"conclusions"` + Decisions []SupplyChainDecision `json:"decisions"` + PolicyResults []SupplyChainPolicyResult `json:"policy_results"` +} + +type SupplyChainReviewEvent struct { + ID int64 `json:"id"` + Kind string `json:"kind"` + Actor string `json:"actor"` + RepositoryID *int64 `json:"repository_id"` + Target string `json:"target"` + Detail map[string]any `json:"detail"` + CreatedAt time.Time `json:"created_at"` +} + +type SupplyChainPolicy struct { + ID int64 `json:"id"` + Name string `json:"name"` + Version int `json:"version"` + Kind string `json:"kind"` + Active bool `json:"active"` + UnknownHandling string `json:"unknown_handling"` + Description string `json:"description"` + CreatedBy string `json:"created_by"` + Approved []string `json:"approved"` + ReviewRequired []string `json:"review_required"` + Prohibited []string `json:"prohibited"` + AllowOrLater bool `json:"allow_or_later"` +} + +type SupplyChainCreatePolicyRequest struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + // Rules is the JSON policy body (approved/review_required/prohibited lists). + Rules json.RawMessage `json:"rules,omitempty"` + UnknownHandling string `json:"unknown_handling,omitempty"` + Activate bool `json:"activate"` + InstallExample bool `json:"install_example,omitempty"` +} diff --git a/test/integration/supply_chain_test.go b/test/integration/supply_chain_test.go index 50c0a953..31af3fbd 100644 --- a/test/integration/supply_chain_test.go +++ b/test/integration/supply_chain_test.go @@ -26,6 +26,7 @@ import ( "github.com/balcsida/graphnest/internal/postgres" "github.com/balcsida/graphnest/internal/supplychain" "github.com/balcsida/graphnest/internal/supplychain/license" + "github.com/balcsida/graphnest/internal/supplychain/review" "github.com/balcsida/graphnest/pkg/api" ) @@ -965,3 +966,250 @@ func TestSupplyChainDerivedSPDXExport(t *testing.T) { t.Fatalf("unauthorized derived export = %d", response.Code) } } + +// TestSupplyChainReviewWorkflow proves the review queue, policy evaluation +// against the example policy, human conclusions with optimistic concurrency, +// scoped decisions with expiry, the permission matrix, and immutable history. +func TestSupplyChainReviewWorkflow(t *testing.T) { + h := newPostgresHarness(t) + widgets := h.seedRepository(t, 10, 101) + gadgets := h.seedRepository(t, 10, 102) + github, client := newFakeSBOMGitHub(t) + envelope := sbomEnvelope(t) + github.responses["acme/repo-101"] = func(writer http.ResponseWriter) { fmt.Fprint(writer, envelope) } + github.responses["acme/repo-102"] = func(writer http.ResponseWriter) { fmt.Fprint(writer, envelope) } + collector := &supplychain.Collector{Store: h.store, GitHub: client, Owner: "collect", MaxDocumentBytes: 1 << 20} + for _, id := range []int64{widgets, gadgets} { + if _, _, err := h.store.EnqueueSupplyChainJob(t.Context(), id, supplychain.StreamGitHubSource, "manual", "t", 10, time.Now()); err != nil { + t.Fatal(err) + } + if _, err := collector.RunOnce(t.Context()); err != nil { + t.Fatal(err) + } + } + // Registry evidence: left-pad resolves to GPL-3.0-only (prohibited by the example), core to MIT (approved). + assess := func(coordinates license.Coordinates, raw string) { + if _, _, err := h.store.InsertLicenseEvidence(t.Context(), license.Evidence{Source: license.SourceRegistryNPM, Route: "npm:test", Coordinates: coordinates, Outcome: license.OutcomeResolved, FetchedAt: time.Now(), RawValue: raw, RawKind: license.RawExpression, + ParseStatus: "parsed", NormalizedExpression: raw, ResolverVersion: 1, LicenseListVersion: "3.27.0", ContentSHA256: make([]byte, 32)}); err != nil { + t.Fatal(err) + } + occurrences, _ := h.store.ComponentsForCoordinates(t.Context(), coordinates, 10) + evidence, _ := h.store.LatestLicenseEvidence(t.Context(), coordinates) + for _, occurrence := range occurrences { + declared, concluded, _ := h.store.ComponentDeclarations(t.Context(), occurrence[0]) + if err := h.store.UpsertAssessment(t.Context(), license.AssessWithHuman(occurrence[0], occurrence[1], declared, concluded, evidence, time.Now())); err != nil { + t.Fatal(err) + } + } + } + leftPad := license.Coordinates{Ecosystem: "npm", Namespace: "@scope", Name: "left-pad", Version: "1.3.0"} + core := license.Coordinates{Ecosystem: "maven", Namespace: "org.example", Name: "core", Version: "2.1.0"} + assess(leftPad, "GPL-3.0-only") + assess(core, "MIT") + + authorizer := authz.NewPostgres(h.store) + reviews := &review.Service{Store: h.store, Authorizer: authorizer, MaxResults: 100} + authenticator := authn.RequestAuthenticator{Bearer: authn.NewStatic(map[string]authn.Principal{ + "admin": {Subject: "admin@acme", Method: "session", Administrator: true}, + "reviewer": {Subject: "reviewer@acme", Method: "api_token", InstallationID: 10, RepositoryIDs: []int64{101, 102}}, + "reader": {Subject: "reader@acme", Method: "api_token", InstallationID: 10, RepositoryIDs: []int64{101}}, + "outsider": {Subject: "outsider", Method: "api_token", InstallationID: 20, RepositoryIDs: []int64{999}}, + })} + mux := http.NewServeMux() + httpapi.RegisterSupplyChainReview(mux, authenticator, reviews, 64<<10, 256<<10) + call := func(token, method, path, body string) *httptest.ResponseRecorder { + request := httptest.NewRequest(method, path, strings.NewReader(body)) + request.Header.Set("Authorization", "Bearer "+token) + if body != "" { + request.Header.Set("Content-Type", "application/json") + } + recorder := httptest.NewRecorder() + mux.ServeHTTP(recorder, request) + return recorder + } + decode := func(t *testing.T, recorder *httptest.ResponseRecorder, want int, target any) { + t.Helper() + if recorder.Code != want { + t.Fatalf("status %d (want %d): %s", recorder.Code, want, recorder.Body.String()) + } + if target != nil { + if err := json.Unmarshal(recorder.Body.Bytes(), target); err != nil { + t.Fatal(err) + } + } + } + + // Policy administration is administrator-only; the example is labelled and can be installed, then activated. + if response := call("reviewer", http.MethodPost, "/v1/supply-chain/policies", `{"install_example":true,"activate":true}`); response.Code != http.StatusForbidden { + t.Fatalf("reviewer installed a policy: %d", response.Code) + } + var installed api.SupplyChainPolicy + decode(t, call("admin", http.MethodPost, "/v1/supply-chain/policies", `{"install_example":true,"activate":true}`), http.StatusCreated, &installed) + if installed.Kind != "example" || !installed.Active || installed.UnknownHandling != "review_required" || !strings.Contains(installed.Description, "EXAMPLE FIXTURE") { + t.Fatalf("installed = %+v", installed) + } + if response := call("admin", http.MethodPost, "/v1/supply-chain/policies", `{"name":"acme","rules":{"approved":["MIT","Not-Real"]},"unknown_handling":"prohibited"}`); response.Code != http.StatusBadRequest { + t.Fatalf("invalid rules accepted: %d %s", response.Code, response.Body.String()) + } + // No pretend company policy: default unknown handling must not auto-approve. + if response := call("admin", http.MethodPost, "/v1/supply-chain/policies", `{"name":"acme","rules":{"approved":["MIT"]},"unknown_handling":"approved"}`); response.Code != http.StatusBadRequest { + t.Fatalf("unknown_handling=approved accepted: %d", response.Code) + } + evaluated, err := reviews.EvaluatePending(t.Context(), 500) + if err != nil || evaluated != 14 { + t.Fatalf("evaluated %d err=%v", evaluated, err) + } + if evaluated, err := reviews.EvaluatePending(t.Context(), 500); err != nil || evaluated != 0 { + t.Fatalf("re-evaluation without change = %d %v", evaluated, err) + } + + // Queue: scoped to the caller; the approved MIT component is not in it; prohibited and unknown ones are. + var queue api.SupplyChainReviewQueue + decode(t, call("reviewer", http.MethodGet, "/v1/supply-chain/review/queue", ""), http.StatusOK, &queue) + verdicts := map[string]int{} + var leftPadItem *api.SupplyChainReviewItem + for index := range queue.Items { + verdicts[queue.Items[index].Verdict]++ + if queue.Items[index].Coordinates.Name == "left-pad" && queue.Items[index].RepositoryID == 101 { + leftPadItem = &queue.Items[index] + } + if queue.Items[index].Coordinates.Name == "core" { + t.Fatalf("approved component in queue: %+v", queue.Items[index]) + } + } + if len(queue.Items) != 12 || verdicts["prohibited"] != 2 || verdicts["review_required"] != 10 || leftPadItem == nil || leftPadItem.Basis == "" || leftPadItem.Reason != "no decision recorded" { + t.Fatalf("queue = %d items, verdicts %v, left-pad %+v", len(queue.Items), verdicts, leftPadItem) + } + decode(t, call("reader", http.MethodGet, "/v1/supply-chain/review/queue", ""), http.StatusOK, &queue) + if len(queue.Items) != 6 { + t.Fatalf("reader queue = %d items (must cover only repository 101)", len(queue.Items)) + } + decode(t, call("outsider", http.MethodGet, "/v1/supply-chain/review/queue", ""), http.StatusOK, &queue) + if len(queue.Items) != 0 { + t.Fatalf("outsider queue = %d items", len(queue.Items)) + } + + // Permission matrix for decisions: reader lacks a grant; reviewer needs one; outsider cannot even see the repository. + decisionBody := func(kind, basis, expires string) string { + body := fmt.Sprintf(`{"repository_id":101,"coordinates":{"ecosystem":"npm","namespace":"@scope","name":"left-pad","version":"1.3.0"},"kind":%q,"reason":"pilot needs it","basis":%q`, kind, basis) + if expires != "" { + body += `,"expires_at":"` + expires + `"` + } + return body + "}" + } + if response := call("reader", http.MethodPost, "/v1/supply-chain/review/decisions", decisionBody("approve", leftPadItem.Basis, "")); response.Code != http.StatusForbidden { + t.Fatalf("reader decided: %d", response.Code) + } + if response := call("reviewer", http.MethodPost, "/v1/supply-chain/review/decisions", decisionBody("approve", leftPadItem.Basis, "")); response.Code != http.StatusForbidden { + t.Fatalf("ungranted reviewer decided: %d", response.Code) + } + if response := call("outsider", http.MethodPost, "/v1/supply-chain/review/decisions", decisionBody("approve", leftPadItem.Basis, "")); response.Code != http.StatusNotFound { + t.Fatalf("outsider decided: %d", response.Code) + } + if response := call("reviewer", http.MethodPut, "/v1/supply-chain/review/grants", `{"repository_id":101,"subject":"reviewer@acme","allow":true}`); response.Code != http.StatusForbidden { + t.Fatalf("reviewer granted themselves: %d", response.Code) + } + decode(t, call("admin", http.MethodPut, "/v1/supply-chain/review/grants", `{"repository_id":101,"subject":"reviewer@acme","allow":true}`), http.StatusNoContent, nil) + // Stale basis is refused; an exception needs an expiry; a wrong kind is invalid. + if response := call("reviewer", http.MethodPost, "/v1/supply-chain/review/decisions", decisionBody("exception", strings.Repeat("0", 64), "2027-01-01T00:00:00Z")); response.Code != http.StatusConflict { + t.Fatalf("stale basis accepted: %d %s", response.Code, response.Body.String()) + } + if response := call("reviewer", http.MethodPost, "/v1/supply-chain/review/decisions", decisionBody("exception", leftPadItem.Basis, "")); response.Code != http.StatusBadRequest { + t.Fatalf("exception without expiry accepted: %d", response.Code) + } + if response := call("reviewer", http.MethodPost, "/v1/supply-chain/review/decisions", decisionBody("allow", leftPadItem.Basis, "")); response.Code != http.StatusBadRequest { + t.Fatalf("unknown kind accepted: %d", response.Code) + } + var decision api.SupplyChainDecision + decode(t, call("reviewer", http.MethodPost, "/v1/supply-chain/review/decisions", decisionBody("exception", leftPadItem.Basis, time.Now().Add(48*time.Hour).UTC().Format(time.RFC3339))), http.StatusCreated, &decision) + if decision.Kind != "exception" || decision.PolicyVerdict != "prohibited" || decision.Reviewer != "reviewer@acme" || decision.ExpiresAt == nil || decision.PolicyID == nil { + t.Fatalf("decision = %+v", decision) + } + // The exception removes the occurrence from the queue for repository 101 only; the policy verdict stays prohibited. + decode(t, call("reviewer", http.MethodGet, "/v1/supply-chain/review/queue", ""), http.StatusOK, &queue) + for _, item := range queue.Items { + if item.Coordinates.Name == "left-pad" && item.RepositoryID == 101 { + t.Fatalf("excepted occurrence still queued: %+v", item) + } + } + if len(queue.Items) != 11 { + t.Fatalf("queue after exception = %d", len(queue.Items)) + } + var history api.SupplyChainReviewHistory + decode(t, call("reader", http.MethodGet, "/v1/supply-chain/review/history?repository_id=101&ecosystem=npm&namespace=%40scope&name=left-pad&version=1.3.0", ""), http.StatusOK, &history) + if history.Current == nil || history.Current.ID != decision.ID || history.CurrentStale || len(history.PolicyResults) != 1 || history.PolicyResults[0].Verdict != "prohibited" || history.Basis != leftPadItem.Basis { + t.Fatalf("history = %+v", history) + } + + // A human conclusion on left-pad (MIT) supersedes the registry's GPL evidence, changes the basis, marks the exception stale, and re-evaluates to approved. + conclusionBody := fmt.Sprintf(`{"repository_id":101,"coordinates":{"ecosystem":"npm","namespace":"@scope","name":"left-pad","version":"1.3.0"},"expression":"MIT","reason":"registry metadata is wrong; LICENSE file says MIT","basis":%q}`, leftPadItem.Basis) + if response := call("reviewer", http.MethodPost, "/v1/supply-chain/review/conclusions", strings.Replace(conclusionBody, `"MIT"`, `"see LICENSE"`, 1)); response.Code != http.StatusBadRequest { + t.Fatalf("free-text conclusion accepted: %d", response.Code) + } + var conclusion api.SupplyChainConclusion + decode(t, call("reviewer", http.MethodPost, "/v1/supply-chain/review/conclusions", conclusionBody), http.StatusCreated, &conclusion) + if conclusion.Reviewer != "reviewer@acme" || conclusion.Basis != leftPadItem.Basis { + t.Fatalf("conclusion = %+v", conclusion) + } + if response := call("reviewer", http.MethodPost, "/v1/supply-chain/review/conclusions", conclusionBody); response.Code != http.StatusConflict { + t.Fatalf("replaying a conclusion on the old basis must be stale: %d", response.Code) + } + if evaluated, err := reviews.EvaluatePending(t.Context(), 500); err != nil || evaluated != 2 { + t.Fatalf("re-evaluation after conclusion = %d %v (both left-pad occurrences)", evaluated, err) + } + decode(t, call("reader", http.MethodGet, "/v1/supply-chain/review/history?repository_id=101&ecosystem=npm&namespace=%40scope&name=left-pad&version=1.3.0", ""), http.StatusOK, &history) + if !history.CurrentStale || history.StaleReason != "evidence changed since the decision" || history.Basis == leftPadItem.Basis || len(history.PolicyResults) != 2 || history.PolicyResults[0].Verdict != "approved" || history.PolicyResults[1].Verdict != "prohibited" || len(history.Conclusions) != 1 { + t.Fatalf("history after conclusion = %+v", history) + } + evidence, _ := h.store.LatestLicenseEvidence(t.Context(), leftPad) + sources := map[string]int{} + for _, item := range evidence { + sources[string(item.Source)]++ + } + if sources["human"] != 1 || sources["registry_npm"] != 1 { + t.Fatalf("human conclusion must not erase registry evidence: %v", sources) + } + // The assessment records the override without hiding the automated evidence. + occurrences, _ := h.store.ComponentsForCoordinates(t.Context(), leftPad, 10) + assessments, _ := h.store.SupplyChainAssessments(t.Context(), occurrences[0][1], []int64{occurrences[0][0]}) + if assessment := assessments[occurrences[0][0]]; assessment.Status != license.AssessmentResolved || assessment.NormalizedExpression != "MIT" || !strings.Contains(assessment.ConflictDetail, "GPL-3.0-only") { + t.Fatalf("assessment after conclusion = %+v", assessment) + } + + // Expiry: an expired exception surfaces in the queue with its reason and history marks it stale. + if _, err := h.pool.Exec(t.Context(), `update supply_chain_decisions set expires_at=now()-interval '1 minute' where id=$1`, decision.ID); err != nil { + t.Fatal(err) + } + // Re-decide on the new basis to have a current, then expire it. + var fresh api.SupplyChainDecision + decode(t, call("reviewer", http.MethodPost, "/v1/supply-chain/review/decisions", strings.Replace(decisionBody("exception", history.Basis, time.Now().Add(time.Hour).UTC().Format(time.RFC3339)), `"repository_id":101`, `"repository_id":101`, 1)), http.StatusCreated, &fresh) + if fresh.PolicyVerdict != "approved" { + t.Fatalf("fresh decision verdict = %q, want the re-evaluated approved", fresh.PolicyVerdict) + } + if _, err := h.pool.Exec(t.Context(), `update supply_chain_decisions set expires_at=now()-interval '1 minute' where id=$1`, fresh.ID); err != nil { + t.Fatal(err) + } + decode(t, call("reader", http.MethodGet, "/v1/supply-chain/review/history?repository_id=101&ecosystem=npm&namespace=%40scope&name=left-pad&version=1.3.0", ""), http.StatusOK, &history) + if !history.CurrentStale || history.StaleReason != "exception expired" || len(history.Decisions) != 2 || history.Decisions[1].SupersededBy == nil || *history.Decisions[1].SupersededBy != fresh.ID { + t.Fatalf("history after expiry = %+v", history) + } + // Audit history is append-only and scoped. + var events struct { + Events []api.SupplyChainReviewEvent `json:"events"` + } + decode(t, call("reader", http.MethodGet, "/v1/supply-chain/review/events", ""), http.StatusOK, &events) + kinds := map[string]int{} + for _, event := range events.Events { + kinds[event.Kind]++ + } + if kinds["policy_created"] != 1 || kinds["review_grant_changed"] != 1 || kinds["decision_recorded"] != 2 || kinds["conclusion_recorded"] != 1 { + t.Fatalf("events = %v", kinds) + } + if _, err := h.pool.Exec(t.Context(), `delete from supply_chain_review_events`); err == nil || !strings.Contains(err.Error(), "append-only") { + t.Fatalf("review events were deletable: %v", err) + } + if _, err := h.pool.Exec(t.Context(), `update supply_chain_decisions set reason='edited' where id=$1`, decision.ID); err != nil { + t.Fatal(err) + } + _ = gadgets +}