Repository navigation
feat(ssp-templates): SSP template model and admin CRUD routes - #522
Merged
gusfcarvalho merged 5 commits intoOct 22, 2026
Merged
Conversation
Add SSPTemplate and SSPTemplateInstance with their foreign keys, template validation, and /api/admin/ssp-templates list/create/get/update/delete behind admin:manage. Keys cannot change after creation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Important Review skippedBot user detected. To trigger a single review, invoke the ⚙️ Run configuration
You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Contract addition from review: template responses carry the number of SSPs the template created that still exist, and the time of its last completed discovery run (a new nullable last_run_at column, set by discovery in a later PR). The template instance foreign keys move into EnsureSSPConstraints (renamed from EnsureSSPScopeConstraints). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 19, 2026
The enabled column's default made gorm write true over a false Enabled on create. Drop the column default; Build always sets Enabled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 19, 2026
gusfcarvalho
pushed a commit
that referenced
this pull request
Oct 22, 2026
…vice (#523) ## Place in the stack SSP templates (feature 2 of `ssp-templates.md`), PR **B2 of 5**. Base: B1 (#522, `feat/ssp-templates-1-model`), on top of the SSP scope stack (#516–#520). ## What it adds A pure refactor, with no behaviour change. SSP creation and profile attachment move out of `SystemSecurityPlanHandler.Create` and `AddProfile` into a new `internal/service/relational/sspservice.Service`. B4's discovery needs the same code to create SSPs inside its own transaction. - `Create(db, oscalSSP)` unmarshals the SSP, stamps last-modified and the OSCAL version, and stores it. Callers validate the SSP first; the handler still runs `validateSSPInput`. - `AttachProfile(db, sspID, profileID)` takes the same steps as before: 1. load the SSP and the profile; 2. insert the binding with `ON CONFLICT DO NOTHING`; 3. resolve the profile's controls; 4. ensure a control implementation exists; 5. create the missing implemented requirements, matched case-insensitively and stored with canonical casing. The writes run in `db.Transaction`, which becomes a savepoint when `db` is already a transaction. It returns `ErrSSPNotFound`, `ErrProfileNotFound`, `ErrProfileAlreadyBound` or `ErrNoResolvedControls`, and the handler maps them to the same status codes and messages as before (404/404/409/400). - Profile control resolution stays in the oscal handler: the cache, the pivot table and the full-resolution fallback. It is injected as a `ControlResolver` func, because services cannot import `internal/api`. ## How it was tested - Existing SSP handler suites pass unchanged: the whole `internal/api/handler/oscal` integration package, including `system_security_plans*` and `system_security_plan_profiles_integration_test`. - New `sspservice/service_integration_test.go` covers: - attachment creates only the missing requirements, case-insensitively; - the not-found, already-bound and no-controls errors, with no binding left behind; - both calls take part in a caller's transaction, so a rollback leaves nothing. - Gate on this branch: - `make swag` (no doc change), `make lint` (only the existing SA1019 in `internal/api/metrics.go`), `make test`, `go vet -tags integration ./...`; - integration suites of `internal/api/handler/oscal` and `sspservice`; - `make check-diff`. Changed lines excluding generated swagger: 385. ## Deviations from design None. The control resolver is injected rather than moved into the service; see the `lisa-design` note. B4 wires the same resolver into the discovery worker. ## Open questions for reviewers - Should profile control resolution (`getControlIDsForProfile`, `extractControlIDsFromProfile`, the process-wide cache) move out of the oscal handler package into a service? This PR injects it to stay a pure refactor. B4 passes the handler's resolver to the worker from where both are built. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: ccf-lisa[bot] <286799724+ccf-lisa[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
gusfcarvalho
pushed a commit
that referenced
this pull request
Oct 22, 2026
## Place in the stack SSP templates (feature 2 of `ssp-templates.md`), PR **B3 of 5**. Base: B2 (#523, `feat/ssp-templates-2-ssp-service`). Below it: B1 (#522) and the SSP scope stack (#516–#520). ## What it adds - `ssptemplates.BuildPlan(tmpl, streams, instances, entries, exceptions)` is the pure planner that preview and B4's discovery job share. It: - keeps the latest streams that match the template's filter and carry every key with exactly one non-empty value; the others count as `missing-keys`; - groups the kept streams by key values, and lists groups under `min-streams` as `below-threshold`; - reports groups whose key values fail `sspscope.Normalize`, or whose title fails to render or renders empty, under `invalid`; - plans the entries each remaining group would learn: the key entry (all keys ANDed), plus every combination of each scope-label group's values seen on at least `min-streams` of the group's streams. Multi-valued labels expand to every combination. - drops entries already in the SSP's scope, moves excepted ones to `skipped-by-exception`, and moves entries a scope cannot hold to `invalid-entries`; - keeps the evidence's value casing. `LatestEvidenceStream` gains `RawLabels`: the trimmed original values, index for index with the folded `Labels`, which are unchanged for other callers. The filter, key checks and grouping still use the folded labels, so `Payments` and `payments` are one group with one `KeyHash`. Titles, key values and learned entries come from the group's first stream by UUID; scope-label combinations count case-insensitively. - marks a group `exists` when an instance records it. An instance whose SSP was deleted (`ssp-id` null) gets nothing to learn. - `POST /api/admin/ssp-templates/preview` takes a template, saved or not, with an optional `id` so a saved template's instances mark existing SSPs. It loads the latest evidence and the instances' scope hashes, runs the planner and writes nothing. It sits behind the group's JWT + `admin:manage`. Swagger is regenerated. ## How it was tested - Unit (`plan_test.go`): - grouping by several keys; - missing, empty and multi-valued keys; - the filter; - min-streams for groups and for scope-label combinations; - scope-label groups with multi-valued labels; - existing and deleted instances, known entries and exceptions; - invalid key values and entries; - an empty rendered title; - de-duplication of a key repeated in a scope-label group; - mixed-case evidence: grouping is case-insensitive, and titles, key values and learned entries keep their casing. - Integration under the embedded Cedar PDP (`TestPreview`): - an unsaved template, and a saved one whose instance and exception change the result (mixed-case evidence; the title keeps its casing); - nothing is written; - unknown id 404, invalid template 400, contributor 403. - Gate on this branch: - `make swag`, `make lint` (only the existing SA1019), `make test`, `go vet -tags integration ./...`; - integration suites of `internal/api/handler/templates`, `ssptemplates`, `sspscope`, `internal/service/worker` and `internal/service/relational/evidence`; - `make check-diff`. Changed lines excluding generated swagger: 662. ## Deviations from design - The function is named `BuildPlan`, because `Plan` is its result type. Its arguments are `(tmpl, streams, instances, entries, exceptions)`. - The response adds `key-hash`, `ssp-id` and `invalid-entries` to each planned SSP. - `missing-keys` also counts streams whose key is multi-valued, since they cannot be grouped. - `below-threshold` lists groups only. Scope-label combinations under `min-streams` are dropped without being listed. - A group whose title renders empty is `invalid`, so no SSP with an empty title is created. ## Open questions for reviewers - Should `below-threshold` also list scope-label combinations under `min-streams`? The design's wording says "groups or entries", but its example shows groups only. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: ccf-lisa[bot] <286799724+ccf-lisa[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
gusfcarvalho
pushed a commit
that referenced
this pull request
Oct 22, 2026
## Place in the stack SSP templates (feature 2 of `ssp-templates.md`), PR **B4 of 5**. Base: B3 (#524, `feat/ssp-templates-3-planner`). Below it: B2 (#523), B1 (#522) and the SSP scope stack (#516–#520). The design's B4 is split in two to stay under 1000 lines. This PR is discovery. B5 (`feat/ssp-templates-5-routes`) adds the run route, the instances list (stale, `key-hash`, `skipped-entries`) and the forget route. ## What it adds - `ssptemplates.Discovery` runs the B3 planner against the latest evidence and applies it: - **New group:** creates the SSP through B2's `sspservice` in a transaction holding the template's advisory lock, records the instance, and learns the scope (`sspscope.Service.Learn`, origin `template`). - The SSP id is `uuid.NewSHA1(namespace, templateID || keyHash)`. - `metadata.title` is the rendered title and `metadata.version` is `1.0`. - Props are the rendered props plus `ssp-template=<template id>`. - `system-name` is the title, the description is rendered (or falls back to the title), and status is `under-development`. Profiles are attached after that transaction commits. A failed attach is recorded in `last_error` and `profiles_pending`, and the next run retries it. - **Existing group:** refreshes `last_seen_at` and learns new scope entries. Learn already skips exceptions and existing entries. - **Deleted SSP** (instance `ssp_id` null): left alone, never recreated. - At most `MaxNewSSPsPerRun` (100) new SSPs per template per run; the next run creates the rest. - Learned entries the scope cannot hold (from the planner, and `LearnResult.Skipped`) go into new instance columns `skipped_entries` and `skipped_sample` (up to 5). - A failing group gets `last_error` and the run's error; the other groups still apply. `last_run_at` is set when the run finishes. This is the contract addition from review: B1 exposes it as `last-run-at`, null until this job sets it. - River worker and periodic job `ssp_template_discovery` on the `scheduler` queue. Empty args run every enabled template, loading the evidence once; args with `template_id` run one template, for B5's run route. - The worker reuses the `ProfileControlResolver` that `cmd/run.go` already injects (the oscal handler's control resolution), adapted to `sspservice.ControlResolver`. - Config: `CCF_SSP_TEMPLATE_DISCOVERY_SCHEDULE` (default hourly, `0 37 * * * *`) and `CCF_SSP_TEMPLATE_STALE_AFTER` (default `168h`, used by B5), in `internal/config` and `.env.example`. ## How it was tested - Integration (`ssptemplates/discovery_integration_test.go`, testcontainers): - the SSP is created with the evidence's casing, the deterministic id, title, description, props, the template's profile and its implemented requirements; scope entries have origin `template` and the template id; `last_run_at` is set; - runs are idempotent (one SSP, `last_seen_at` refreshed); - later runs learn new values (evidence in other casing joins the same SSP) and respect exceptions; - a deleted SSP is never recreated; - the 100-per-run cap: 101 groups give 100, then 1; - a failed profile attach is recorded and then retried successfully; - disabled templates are skipped; - invalid learned entries are counted and sampled. - Unit: the config loader, the schedule fallback, and the resolver adapter's case-insensitive de-duplication. - Gate on this branch: - `make swag` (no doc change), `make lint` (only the existing SA1019), `make test`, `go vet -tags integration ./...`; - integration suites of `internal/service/worker`, `ssptemplates`, `internal/service`, `internal/service/relational`, `internal/config`; - `make check-diff`. Changed lines excluding generated swagger: 762. ## Deviations from design - The design's B4 is split into B4 (discovery) and B5 (routes). - New instance columns, not in the design's model: - `profiles_pending`: retry an attach only while one failed, so a profile a user removed is never re-attached; - `skipped_entries` / `skipped_sample`: the design's "skipped count and a sample". - The `ssp-template` prop uses the existing `relational.CCFOSCALNamespace` (`https://compliance-framework.github.io/ns/oscal`), not `…/ns`. - Rendered prop values that come out empty are dropped, because OSCAL props need a value. - `plan.InvalidEntry` is now an alias of `relational.SkippedScopeEntry`. ## Open questions for reviewers - An existing SSP whose evidence drops under `min-streams` is not refreshed, so it can be reported stale even though some evidence remains. Should `min-streams` apply only to creating SSPs? - The `ssp-template` prop namespace: the existing `…/ns/oscal` constant, or the design's `…/ns`? 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: ccf-lisa[bot] <286799724+ccf-lisa[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
gusfcarvalho
pushed a commit
that referenced
this pull request
Oct 22, 2026
## Place in the stack SSP templates (feature 2 of `ssp-templates.md`), PR **B5 of 5**. It is the second half of the design's B4, split to keep each PR under 1000 lines. Base: B4 (#525, `feat/ssp-templates-4-discovery`). Below it: B3 (#524), B2 (#523), B1 (#522) and the SSP scope stack (#516–#520). ## What it adds All three routes sit behind the existing `/api/admin/ssp-templates` group: JWT + `admin:manage`. - `POST /api/admin/ssp-templates/{id}/run` queues a discovery run of the template (`ssp_template_discovery` with the template id as args) and answers **202**. - 404 for an unknown template, **409** for a disabled one. - 503 when no job queue is wired, and 500 when the worker is disabled. - Runs are unique by args among waiting or running jobs, so a finished run never blocks the next one. - `worker.Service.EnqueueSSPTemplateDiscovery` implements the new `templates.SSPTemplateRunEnqueuer`, wired through `APIServices.SSPTemplateRunEnqueuer` in `cmd/run.go`. - `GET /api/admin/ssp-templates/{id}/ssps` is paginated and newest first. Each item has: - `key-values` and **`key-hash`** (a contract addition from review: the forget route's id); - `ssp-id` (null once the SSP was deleted) and the SSP's current `title`; - `created-at` and `last-seen-at`; - `stale`: the SSP exists and was last seen more than `CCF_SSP_TEMPLATE_STALE_AFTER` ago (from B4's config); - `last-error`, `skipped-entries` and `skipped-sample`. - `DELETE /api/admin/ssp-templates/{id}/ssps/{keyHash}` forgets an instance whose SSP was deleted, so the next run creates it again. It answers 409 while the SSP exists and 404 for an unknown instance. Swagger is regenerated. ## How it was tested - Integration under the embedded Cedar PDP (`ssp_template_instances_integration_test.go`): - run: queued once, disabled 409, unknown 404, contributor 403, no job queue 503; - list: key values, key hash, title, stale/fresh/deleted, last error, skipped entries; contributor 403; unknown template 404; - forget: 409 while the SSP exists, 404 unknown, contributor 403, 204 for a deleted SSP, and the list shrinks afterwards. - Unit: enqueueing with the worker disabled is an error. - Gate on this branch: - `make swag`, `make lint` (only the existing SA1019), `make test`, `go vet -tags integration ./...`; - integration suites of `internal/api/handler`, `internal/api/handler/templates`, `ssptemplates`, `internal/service/worker`; - `make check-diff`. Changed lines excluding generated swagger: 395. ## Deviations from design - This PR exists only because B4 was split. - The instance list is paginated, and adds `title`, `key-hash` (contract addition) and `skipped-sample` to the design's fields. - A run requested while the worker is disabled answers 500 instead of a 202 for a run that would never happen. ## Open questions for reviewers - Should a run requested while the worker is disabled answer 503 rather than 500? Telling that case apart from an enqueue failure needs an error exported from the worker package. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: ccf-lisa[bot] <286799724+ccf-lisa[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Place in the stack
SSP templates (feature 2 of
ssp-templates.md), PR B1 of 5 (B1–B5: #522, #523, #524, #525, #526), stacked on the SSP scope stack (#516–#520, topfeat/ssp-scope-5-auto-filters).What it adds
SSPTemplateandSSPTemplateInstance(internal/service/relational/ssp_templates.go), in both AutoMigrate lists and both drop lists. Their foreign keys (instance → templateON DELETE CASCADE, instance → SSPON DELETE SET NULL) are added byEnsureSSPConstraints, which isEnsureSSPScopeConstraintsrenamed, with two keys appended. AutoMigrate here does not enforce gorm FK tags.internal/service/relational/ssptemplates:Build: validation and normalization. Keys are trimmed, lower-cased and sorted (1–5). Scope-label groups are normalized the same way (≤10 groups of 1–5 labels). Keys and labels followsspscope.Normalize's key rules, so anything a template accepts can be stored as a scope entry.min-streamsmust be ≥ 1. The title template is required, and the title, description and prop templates must parse. The labelfilter must be valid.Service: list/get/create/update/delete. Profile ids must exist. Keys cannot change on update (400)./api/admin/ssp-templates(list, create, get, update, delete), behindJWTMiddleware+pep.Authorize(authz.ResourceAdmin, authz.ActionManage), like/admin/subject-templates. JSON is kebab-case. Swagger is regenerated.ssp-countandlast-run-at.ssp-countis the number of instances whose SSP still exists.last-run-atis a new nullablelast_run_atcolumn. It staysnulluntil B4's discovery job sets it, and Update preserves it.Not in this PR: preview (B3), SSP creation (B2/B4), discovery, run, instances routes (B4).
How it was tested
ssptemplates/validate_test.go. It covers normalization, defaults, and every rejection rule.internal/api/handler/templates/ssp_template_integration_test.go) runs under the embedded Cedar PDP:ssp-count: deleting an SSP nulls its instance'sssp_idand drops it fromssp-count, and deleting a template drops its instances and keeps its SSPs.make swag,make lint(only the existing SA1019 ininternal/api/metrics.go),make test,go vet -tags integration ./...;internal/api/handler,internal/api/handler/templates,internal/service,internal/service/relational,internal/service/relational/sspscope;make check-diff.Changed lines excluding generated swagger: 1000.
Deviations from design
skipped-entriescount and sample reported by the instances route are added in B4, with the code that writes them.{}/{"scope":{}}) matches everything, so it is stored as no filter.enabledcolumn has no database default (the design hasdefault:true). gorm writes a column default over a falseEnabledon create, so a disabled template was stored as enabled.Buildalways setsEnabled, with nil meaning true.svc.ListResponse,page/limit) like subject templates.created-at/updated-at), unlike subject templates' camelCase ones.Open questions for reviewers
{{ ._APP }}would render empty)? The design asks only for parse errors.🤖 Generated with Claude Code