diff --git a/config/navigation.json b/config/navigation.json index a0ed1b8..2604d91 100644 --- a/config/navigation.json +++ b/config/navigation.json @@ -120,7 +120,8 @@ { "group": "Evaluation", "pages": [ - "tutorials/evaluate_trails_with_opa" + "tutorials/evaluate_trails_with_opa", + "tutorials/evaluate_policies_server_side" ] }, { diff --git a/images/tutorials/controls-coverage.png b/images/tutorials/controls-coverage.png new file mode 100644 index 0000000..e204fcc Binary files /dev/null and b/images/tutorials/controls-coverage.png differ diff --git a/images/tutorials/controls-decision-detail.png b/images/tutorials/controls-decision-detail.png index 6081e52..dd1aa5b 100644 Binary files a/images/tutorials/controls-decision-detail.png and b/images/tutorials/controls-decision-detail.png differ diff --git a/images/tutorials/controls-decisions.png b/images/tutorials/controls-decisions.png index c8c0e4f..0adb54b 100644 Binary files a/images/tutorials/controls-decisions.png and b/images/tutorials/controls-decisions.png differ diff --git a/images/tutorials/controls-list.png b/images/tutorials/controls-list.png index 7717b83..9f569ed 100644 Binary files a/images/tutorials/controls-list.png and b/images/tutorials/controls-list.png differ diff --git a/images/tutorials/policy-decision-detail.png b/images/tutorials/policy-decision-detail.png new file mode 100644 index 0000000..dfded4c Binary files /dev/null and b/images/tutorials/policy-decision-detail.png differ diff --git a/images/tutorials/policy-decisions.png b/images/tutorials/policy-decisions.png new file mode 100644 index 0000000..c63cf71 Binary files /dev/null and b/images/tutorials/policy-decisions.png differ diff --git a/images/tutorials/policy-list.png b/images/tutorials/policy-list.png new file mode 100644 index 0000000..ea5d1b8 Binary files /dev/null and b/images/tutorials/policy-list.png differ diff --git a/images/tutorials/policy-source.png b/images/tutorials/policy-source.png new file mode 100644 index 0000000..d194097 Binary files /dev/null and b/images/tutorials/policy-source.png differ diff --git a/mockups/policies-ui/.gitignore b/mockups/policies-ui/.gitignore new file mode 100644 index 0000000..f3f494d --- /dev/null +++ b/mockups/policies-ui/.gitignore @@ -0,0 +1,5 @@ +# generated by build.py + Chrome headless +# NB: the stylesheet must NOT be committed -- Mintlify auto-applies any .css in the project +kosli-mock.css +policy-*.html +policy-*.png diff --git a/mockups/policies-ui/README.md b/mockups/policies-ui/README.md new file mode 100644 index 0000000..0dea1c8 --- /dev/null +++ b/mockups/policies-ui/README.md @@ -0,0 +1,25 @@ +# Proposed Policies UI — wireframe mockups + +Sources for the mockup images used by +[`tutorials/evaluate_policies_server_side.mdx`](../../tutorials/evaluate_policies_server_side.mdx). +They depict a **proposed** UI that does not exist yet, so every rendered page carries a +"Mockup · proposed design" marker. + +Chrome and palette were matched against the real Controls screenshots in +`images/tutorials/controls-*.png` (sidebar `#101c2b`, primary `#1e55cd`, compliant `#3ca36f`, +non-compliant `#ce4a3e`). `logo.png` / `logo-footer.png` are crops of the real logo with the ICC +profile stripped, so they blend with the CSS colors. + +## Regenerate + +```bash +python3 build.py # also writes kosli-mock.css (gitignored) +for p in policy-list policy-source policy-decisions policy-decision-detail; do + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ + --headless=new --disable-gpu --hide-scrollbars --force-device-scale-factor=1 \ + --window-size=1535,1011 --screenshot="$p.png" "file://$PWD/$p.html" +done +cp policy-*.png ../../images/tutorials/ +``` + +1535x1011 at 1x matches the existing Controls screenshots. diff --git a/mockups/policies-ui/_chrome.html b/mockups/policies-ui/_chrome.html new file mode 100644 index 0000000..a9f0568 --- /dev/null +++ b/mockups/policies-ui/_chrome.html @@ -0,0 +1,24 @@ + diff --git a/mockups/policies-ui/_footer.html b/mockups/policies-ui/_footer.html new file mode 100644 index 0000000..d1c7831 --- /dev/null +++ b/mockups/policies-ui/_footer.html @@ -0,0 +1,6 @@ + diff --git a/mockups/policies-ui/_topbar.html b/mockups/policies-ui/_topbar.html new file mode 100644 index 0000000..221ce3c --- /dev/null +++ b/mockups/policies-ui/_topbar.html @@ -0,0 +1,5 @@ +
+
CRUMB
+ +
+
diff --git a/mockups/policies-ui/build.py b/mockups/policies-ui/build.py new file mode 100644 index 0000000..09aa301 --- /dev/null +++ b/mockups/policies-ui/build.py @@ -0,0 +1,464 @@ +#!/usr/bin/env python3 +"""Assemble the proposed-Policies-UI wireframe mockups from shared partials.""" +import pathlib, re + +HERE = pathlib.Path(__file__).parent + +CSS = r"""/* Wireframe mockup chrome for proposed Kosli Policies UI. + Palette sampled from real screenshots in images/tutorials/controls-*.png */ +* { box-sizing: border-box; margin: 0; padding: 0; } +:root { + --navy: #101c2b; + --ink: #14263a; + --grey: #6b7280; + --line: #e6e7e9; + --line-soft: #eef0f2; + --band: #f7f9fc; + --topbar: #f2f2f6; + --blue: #1e55cd; + --green: #3ca36f; + --red: #ce4a3e; + --chip: #eceef2; +} +body { + width: 1535px; min-height: 1011px; display: flex; + font: 400 15px/1.45 -apple-system, "Segoe UI", Inter, Helvetica, Arial, sans-serif; + color: var(--ink); background: #fcfcff; -webkit-font-smoothing: antialiased; +} + +/* ---------- sidebar ---------- */ +.sidebar { width: 250px; flex: 0 0 250px; background: var(--navy); padding: 16px 0 0; } +.logo { display: block; width: 130px; height: 48px; margin: 0 0 18px 10px; } +.org { display: flex; align-items: center; gap: 10px; color: #fff; font-weight: 600; + padding: 0 24px 0 20px; height: 34px; } +.org .home { opacity: .85; } +.org .name { flex: 1; } +.org .chev { opacity: .7; } +hr.sep { border: 0; border-top: 1px solid rgba(255,255,255,.16); margin: 14px 24px 12px; } +nav a { display: flex; align-items: center; gap: 14px; padding: 9px 20px; + color: #b9c2cd; text-decoration: none; font-size: 15px; } +nav a.on { color: #fff; font-weight: 600; } +nav svg { width: 19px; height: 19px; flex: 0 0 19px; } + +/* ---------- top bar ---------- */ +.main { flex: 1; min-width: 0; display: flex; flex-direction: column; } +.topbar { height: 51px; background: var(--topbar); display: flex; align-items: center; + padding: 0 22px 0 36px; gap: 14px; } +.crumb { font-size: 13.5px; color: var(--grey); flex: 1; } +.crumb b { color: var(--ink); } +.search { width: 278px; height: 32px; border: 1px solid #d8dbe0; border-radius: 999px; + background: #fff; display: flex; align-items: center; gap: 8px; padding: 0 14px; + font-size: 13px; color: #9aa1ab; } +.avatar { display: flex; align-items: center; gap: 5px; color: #5b6472; } +.avatar .ring { width: 26px; height: 26px; border-radius: 50%; background: #dfe3e9; + display: grid; place-items: center; } + +/* ---------- page ---------- */ +.page { flex: 1; padding: 26px 36px 0; } +h1 { font-size: 34px; font-weight: 700; letter-spacing: -.4px; line-height: 1.1; } +.h1row { display: flex; align-items: flex-start; gap: 12px; } +.sub { font-size: 16px; font-weight: 600; margin-top: 9px; } +.desc { font-size: 13.5px; color: var(--grey); margin-top: 6px; max-width: 1030px; } +.spacer { flex: 1; } +.toolrow { display: flex; align-items: center; gap: 10px; margin: 18px 0 22px; } + +/* ---------- controls ---------- */ +.field { width: 240px; height: 34px; border: 1px solid #d8dbe0; border-radius: 7px; + background: #fff; display: flex; align-items: center; gap: 8px; padding: 0 11px; + font-size: 13px; color: #9aa1ab; } +.btn { height: 34px; padding: 0 15px; border-radius: 7px; border: 1px solid #d8dbe0; + background: #fff; color: #374151; font-size: 13.5px; font-weight: 500; + display: inline-flex; align-items: center; gap: 7px; } +.btn.pri { background: var(--blue); border-color: var(--blue); color: #fff; font-weight: 600; } +.btn.danger { border-color: #ffdad5; color: #b4433a; } +.btn.sm { height: 27px; padding: 0 10px; font-size: 12.5px; border-radius: 6px; } + +.mock { margin-left: auto; align-self: flex-start; background: #fff7e6; border: 1px solid #f0d18a; + color: #8a6212; border-radius: 6px; font-size: 11.5px; font-weight: 600; + padding: 5px 10px; letter-spacing: .2px; white-space: nowrap; } + +/* ---------- tabs ---------- */ +.tabs { display: flex; gap: 4px; margin-top: 20px; } +.tab { display: flex; align-items: center; gap: 8px; padding: 9px 16px; font-size: 14px; + color: var(--grey); border: 1px solid transparent; border-radius: 8px 8px 0 0; } +.tab.on { background: #fff; border-color: var(--line); border-bottom-color: #fff; + color: var(--ink); font-weight: 600; } + +/* ---------- card ---------- */ +.card { border: 1px solid var(--line); border-radius: 10px; background: #fff; overflow: hidden; } +.card + .card { margin-top: 18px; } +.chead { background: var(--band); border-bottom: 1px solid var(--line); + display: flex; align-items: center; gap: 11px; padding: 13px 18px; font-size: 14.5px; + font-weight: 600; } +.count { background: var(--chip); color: #4b5563; border-radius: 6px; font-size: 12px; + font-weight: 600; padding: 2px 9px; } +.ctab { font-weight: 400; color: var(--grey); font-size: 14px; margin-left: 14px; } +.filters { margin-left: auto; display: flex; gap: 18px; font-size: 13px; color: #4b5563; + font-weight: 400; } +.row { display: flex; align-items: center; gap: 14px; padding: 14px 18px; + border-bottom: 1px solid var(--line-soft); } +.row:last-child { border-bottom: 0; } +.row .grow { flex: 1; min-width: 0; } +.id { font-size: 15px; font-weight: 700; } +.badge { display: inline-block; border: 1px solid #e2e4e8; background: #f7f8fa; color: #4b5563; + border-radius: 4px; font-size: 11px; font-weight: 600; padding: 1px 6px; + vertical-align: 2px; } +.name { font-weight: 400; } +.line2 { font-size: 13px; color: var(--grey); margin-top: 5px; } +.tags { display: flex; gap: 6px; margin-top: 8px; } +.tag { background: #eef2f7; color: #44526a; border-radius: 4px; font-size: 11px; + padding: 2px 8px; } +.kebab { color: #9aa1ab; font-size: 17px; line-height: 1; } +.pill { border-radius: 999px; color: #fff; font-size: 12.5px; font-weight: 600; + padding: 6px 15px 6px 11px; display: inline-flex; align-items: center; gap: 7px; + white-space: nowrap; } +.pill.ok { background: var(--green); } +.pill.no { background: var(--red); } +.pill .dot { width: 15px; height: 15px; border-radius: 50%; background: rgba(255,255,255,.28); + display: grid; place-items: center; font-size: 10px; } +.mono { font: 12.5px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; } +.hint { font-size: 13px; color: var(--grey); margin-top: 14px; } +.hint code { font: 12.5px ui-monospace, Menlo, monospace; background: #f3f4f6; + border-radius: 4px; padding: 1px 5px; } + +/* ---------- footer ---------- */ +footer { display: flex; align-items: center; gap: 18px; padding: 20px 36px 22px; + font-size: 12.5px; color: #4b5563; } +footer .gen { background: var(--chip); color: #6b7280; border-radius: 5px; font-size: 11px; + padding: 3px 9px; } +footer img { width: 82px; height: 34px; } +footer .links { display: flex; gap: 15px; color: var(--blue); } + +/* ---------- detail tray ---------- */ +.scrim { position: fixed; inset: 0; background: rgba(0,0,0,.5); } +.tray { position: fixed; top: 0; right: 0; bottom: 0; width: 843px; background: #fff; + display: flex; flex-direction: column; } +.thead { padding: 30px 32px 20px; } +.trow1 { display: flex; align-items: center; gap: 16px; } +.trow1 h2 { font-size: 30px; font-weight: 700; letter-spacing: -.3px; } +.close { margin-left: auto; color: #6b7280; font-size: 22px; line-height: 1; } +.tmeta { display: flex; align-items: center; gap: 12px; margin-top: 13px; font-size: 13.5px; + color: #4b5563; } +.kv { margin: 0 32px; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; } +.kv > div { display: flex; border-bottom: 1px solid var(--line); } +.kv > div:last-child { border-bottom: 0; } +.kv dt { flex: 0 0 168px; background: var(--band); border-right: 1px solid var(--line); + padding: 13px 15px; font-size: 13.5px; color: var(--grey); } +.kv dd { flex: 1; min-width: 0; padding: 13px 15px; font-size: 13.5px; font-weight: 600; + word-break: break-all; } +.kv dd.plain { font-weight: 400; } +.kv dd ul { margin: 0; padding-left: 18px; font-weight: 400; } +.kv dd li + li { margin-top: 5px; } + +/* ---------- source view ---------- */ +.srcmeta { display: flex; gap: 26px; padding: 13px 18px; border-bottom: 1px solid var(--line-soft); + font-size: 13px; color: var(--grey); } +.srcmeta b { color: var(--ink); font-weight: 600; } +.src { display: flex; font: 12.5px/1.75 ui-monospace, SFMono-Regular, Menlo, monospace; } +.gutter { flex: 0 0 46px; text-align: right; padding: 14px 12px 16px 0; color: #b6bcc5; + background: #fcfcfd; border-right: 1px solid var(--line-soft); user-select: none; } +.code { flex: 1; padding: 14px 0 16px 16px; white-space: pre; overflow: hidden; color: var(--ink); } +.k { color: #8250df; } +.s { color: #0a6e5c; } +.vsel { display: inline-flex; align-items: center; gap: 7px; border: 1px solid #d8dbe0; + background: #fff; border-radius: 6px; padding: 3px 9px; font-size: 12.5px; + font-weight: 500; color: #374151; } + +/* ---------- bundle: file tree + viewer ---------- */ +.bundle { display: flex; align-items: stretch; } +.files { flex: 0 0 252px; border-right: 1px solid var(--line); background: #fcfcfd; + padding: 14px 0 16px; } +.files .cap { font-size: 11px; font-weight: 700; letter-spacing: .6px; color: #9aa1ab; + padding: 0 16px 10px; } +.fi { display: flex; align-items: center; gap: 7px; padding: 6px 16px; font-size: 13px; + color: #374151; border-left: 2px solid transparent; } +.fi.sel { background: #eef3fd; border-left-color: var(--blue); color: var(--blue); + font-weight: 600; } +.fi.nest { padding-left: 34px; } +.fi .flag { margin-left: auto; font-size: 10.5px; color: #9aa1ab; font-weight: 400; } +.dir { padding: 7px 16px 3px; font-size: 13px; color: #6b7280; } +.files .tot { border-top: 1px solid var(--line-soft); margin-top: 12px; padding: 11px 16px 0; + font-size: 12px; color: #9aa1ab; } +.viewer { flex: 1; min-width: 0; } +.vhead { display: flex; align-items: baseline; gap: 12px; padding: 13px 18px; + border-bottom: 1px solid var(--line-soft); font-size: 13.5px; } +.vhead .fn { font-weight: 700; font-size: 14px; } +.vhead .ep { background: #eef3fd; color: var(--blue); border-radius: 4px; font-size: 11px; + font-weight: 600; padding: 2px 7px; } +.vhead .meta { margin-left: auto; color: var(--grey); font-size: 12.5px; } +""" + +(HERE / "kosli-mock.css").write_text(CSS) +chrome = (HERE / "_chrome.html").read_text() +topbar = (HERE / "_topbar.html").read_text() +footer = (HERE / "_footer.html").read_text() + +ICON = ('' + '') +CHEV = ' ▾' +TICK = '' +CROSS = '' + + +def shell(crumb, body, title): + return f"""{title} + +{chrome} +
+{topbar.replace('CRUMB', crumb)} +{body} +{footer} +
""" + + +POLICIES = [ + ("pr-approved", "V1", "Pull request approved", + "Every pull request on the trail has at least one approver.", + ["team=platform", "framework=soc2"]), + ("no-hardcoded-secrets", "V3", "No hard-coded credentials", + "No attested source file matches a known credential pattern.", + ["team=platform", "framework=soc2"]), + ("vuln-scan-clean", "V2", "Vulnerability scan clean", + "The Snyk attestation reports no critical or high findings.", + ["team=security"]), + ("test-evidence-present", "V1", "Test evidence present", + "A JUnit attestation exists on the trail and reports zero failures.", + ["team=quality"]), +] + +DECISIONS = [ + ("14:02:11", "14:02:19", "release-99", "nginx:665d6dd", True), + ("14:00:03", "14:00:11", "release-98", "nginx:665d6dd", True), + ("13:41:55", "13:42:02", "release-97", "nginx:a6e433a", False), + ("13:20:47", "13:20:55", "release-96", "nginx:a6e433a", True), + ("12:58:12", "12:58:19", "release-95", "dashboard:26c6997", True), + ("12:31:09", "12:31:16", "release-94", "dashboard:26c6997", True), + ("11:57:44", "11:57:51", "release-93", "languages-start-points:ca386e0", False), + ("11:12:38", "11:12:46", "release-92", "languages-start-points:ca386e0", True), +] +ECR = "244531986313.dkr.ecr.eu-central-1.amazonaws.com" + + +def policy_rows(): + out = [] + for ident, ver, name, desc, tags in POLICIES: + pills = "".join(f'{t}' for t in tags) + out.append(f"""
+
+ {ident} {ver} +  {name} +
{desc}
+
{pills}
+
+ +
""") + return "\n".join(out) + + +def decision_rows(): + out = [] + for req, rec, trail, art, ok in DECISIONS: + pill = (f'{TICK}Compliant' if ok + else f'{CROSS}Non-compliant') + out.append(f"""
+
+ 2026-07-27 • {req} +  requested · recorded {rec} +   my-release-flow / {trail} +
{ECR}/{art}
+
+ V1 + {pill} +
""") + return "\n".join(out) + + +# ---------------------------------------------------------------- page 1: list +list_body = f"""
+

Policies

+ Mockup • proposed design
+
+
+ Search by name or identifier
+
+
+
{ICON}Evaluation
+
Environment
+
+
+
{ICON}Policies 4 + Archived + Tags{CHEV}Sort{CHEV} +
+{policy_rows()} +
+
Policies are created and updated with the CLI or API + (kosli create policy --type rego). In the beta the UI is read-only, + apart from tagging.
+
""" + +# ----------------------------------------------------------- page 2: decisions +detail_head = f"""

pr-approved

V1 + + + Mockup • proposed design +
+
Pull request approved
+
Every pull request on the trail has at least one approver.
+
+ team=platformframework=soc2 + +
+
+
{ICON}Policy
+
{ICON}Decisions
+
{ICON}Versions
+
""" + +head_decisions = detail_head.replace("TAB_POLICY", "").replace("TAB_DECISIONS", "on") +head_source = detail_head.replace("TAB_POLICY", "on").replace("TAB_DECISIONS", "") + +decisions_card = f"""
+
{ICON}Decisions 128 + Timestamp{CHEV}Compliance{CHEV} + Version{CHEV}Sort{CHEV} + +
+{decision_rows()} +
""" + +decisions_body = f'
\n{head_decisions}\n{decisions_card}\n
' + +# ------------------------------------------------------ page 3: decision detail +tray = f"""
+
+
+

pr-approval-decision

+ {CROSS}Non-compliant +
+
V1 + requested 2026-07-27 • 13:41:55 + ·recorded 13:42:02 + Mockup • proposed design
+
+
+
Evaluation context
evc_7f31a92c +  view
+
Control
RCTL-043 + — Source code review
+
Trail
my-release-flow / release-97
+
Artifact
{ECR}/nginx:a6e433a
+ sha256:a6e433a6fd3eb29c499b75310756420864b6c346
+
Policy
pr-approved V1
+ digest + sha256:9f2c1d84e0b7a35c6f1e8d2b47a90c3518fe64d7
+
Parameters
{{ "pr_attestation_name": "pull-request" }}
+
Evaluation engine
opa 1.18.2
+
Violations
    +
  • pull-request https://github.com/cyber-dojo/nginx/pull/42 has no approvers
  • +
  • pull-request https://github.com/cyber-dojo/nginx/pull/45 has no approvers
  • +
+
Evaluation report
+
+
""" + + +# ------------------------------------------------------- page 4: policy source +REGO = """package policy + +import rego.v1 +import data.lib.approvals + +# METADATA +# scope: document +# schemas: +# - input: schema["trail-v1"] +default allow = false + +allow if { + approvals.all_approved(input.trail, data.params.pr_attestation_name) +} + +violations contains msg if { + some pr in approvals.unapproved(input.trail, data.params.pr_attestation_name) + msg := sprintf("pull-request %v has no approvers", [pr.url]) +}""" + +KEYWORDS = ("package", "import", "default", "allow", "violations", "contains", + "every", "some", "if", "in") + + +def highlight(src): + out = [] + for line in src.split("\n"): + line = (line.replace("&", "&").replace("<", "<").replace(">", ">")) + line = re.sub(r'"([^"]*)"', lambda m: f'"{m.group(1)}"', line) + line = re.sub(r'\b(' + "|".join(KEYWORDS) + r')\b', + lambda m: f'{m.group(1)}', line) + out.append(line) + return "\n".join(out) + + +nlines = len(REGO.split("\n")) +gutter = "
".join(str(i) for i in range(1, nlines + 1)) + +FILES = """
+
FILES
+
policy.rego
+
lib/
+
approvals.rego
+
severity.rego
+
policy_test.regonot executed
+
4 files · 110 lines
+
""" + +source_card = f"""
+
{ICON}Policy bundle + V1 · current{CHEV} + + +
+
+ Bundle sha256:9f2c1d84… + roots policy + entrypoint policy.allow + schema trail + params read pr_attestation_name + published 2026-07-20 by alex@kosli.com +
+
+{FILES} +
+
policy.rego + entrypoint + sha256:9f2c1d84… · {nlines} lines
+
{gutter}
{highlight(REGO)}
+
+
+
+
The bundle is read-only here. Edit it in your repository and publish a new + version with kosli create policy — the version shown is the one that runs + when an evaluation names this policy. Tests are stored with the bundle for audit but are not + executed server-side.
""" + +source_body = f'
\n{head_source}\n{source_card}\n
' + +PAGES = { + "policy-list.html": ("cyber-dojo / policies", list_body, "Policies", ""), + "policy-decisions.html": ("cyber-dojo / policies / pr-approved", + decisions_body, "pr-approved", ""), + "policy-decision-detail.html": ("cyber-dojo / policies / pr-approved", + decisions_body, "pr-approved decision", tray), + "policy-source.html": ("cyber-dojo / policies / pr-approved", + source_body, "pr-approved policy", ""), +} + +for fn, (crumb, body, title, extra) in PAGES.items(): + html = shell(crumb, body, title) + if extra: + html = html.replace("", extra + "\n") + (HERE / fn).write_text(html) + print("wrote", fn) diff --git a/mockups/policies-ui/logo-footer.png b/mockups/policies-ui/logo-footer.png new file mode 100644 index 0000000..1709f8b Binary files /dev/null and b/mockups/policies-ui/logo-footer.png differ diff --git a/mockups/policies-ui/logo.png b/mockups/policies-ui/logo.png new file mode 100644 index 0000000..a290c35 Binary files /dev/null and b/mockups/policies-ui/logo.png differ diff --git a/tutorials/evaluate_policies_server_side.mdx b/tutorials/evaluate_policies_server_side.mdx new file mode 100644 index 0000000..40a22a9 --- /dev/null +++ b/tutorials/evaluate_policies_server_side.mdx @@ -0,0 +1,1001 @@ +--- +title: "Evaluate policies server-side" +description: "Proposed beta design for server-side policy evaluation in Kosli: author and ship a versioned policy, then evaluate it to record a trustworthy, replicable decision." +--- + + +**Proposed design — not yet available.** This page describes planned beta behavior for +server-side policy evaluation and is published to gather feedback *before* the feature is +built. The commands, flags, and output shown are illustrative and will change as the design +firms up. Nothing here can be run against Kosli today. Share comments with the Kosli team. + + +Today, `kosli evaluate` runs **client-side**: the policy lives in your repository, the check +runs on your CI runner, and Kosli only learns the outcome if you **explicitly attest it back** — +a self-reported result Kosli takes on trust. Server-side policy evaluation moves the Policy +Decision Point (PDP) **into Kosli**. You ship a versioned policy to Kosli, Kosli executes it +against facts it already holds, and Kosli **records the decision itself** — one it can vouch for, +connected to the exact policy version and inputs used. + +## Start here + +### The whole loop in two commands + +Publish a policy, then evaluate it. That is the feature: + +```shell +# Publish a versioned policy into Kosli, once. +kosli create policy pr-approved pr-approved.rego \ + --type rego \ + --context-schema trail \ + --name "Pull request approved" + +# Evaluate it from your pipeline, as often as you like. +kosli evaluate policy \ + --flow my-release-flow \ + --policy pr-approved \ + --control RCTL-043 \ + --fingerprint "$ARTIFACT_FINGERPRINT" \ + --name pr-approval-decision \ + --assert +``` + +Kosli runs the policy against the trail's attestations and records a **decision** against control +`RCTL-043`, citing the policy version, the parameters, and the exact facts it used. `--assert` fails +the pipeline step if the policy denies. Everything else on this page is detail around those two +commands. + +### What is in the beta, and what is not + +**In:** publishing versioned policies, evaluating them against a trail or trails, recording decisions +against controls, validating and testing policies, and reviewing or exporting the decisions a policy +has produced. + +**Not in:** evaluating anything other than trails, evaluating the contents of attachments, composing +several policies into one, scheduled evaluations, pinning an evaluation to a policy version, and +advisory-only outcomes. [Full list, with reasons](#whats-out-of-scope-for-the-beta) — worth reading +before you send feedback, so you don't spend it on something already deferred. + +### Where to go next + + + + +For **governance platform engineers**. The core loop, the Rego contract, testing, and managing +policies over time. + + + +For **auditors and control owners**. Review, filter, sample, and export decisions — and re-run one +yourself to check it. + + + +For **application teams**. Understand why a policy denied and what to do about it. + + + + + +This is distinct from the existing client-side +[Evaluate trails with OPA policies](/tutorials/evaluate_trails_with_opa) tutorial, where the +CLI runs the policy locally. Here, Kosli stores the policy and performs the evaluation. + + +## Why server-side + +Running evaluation inside Kosli changes what a decision means — and where the data is processed: + +| | Client-side `kosli evaluate trail` (today) | Server-side `kosli evaluate policy` (proposed) | +|---|---|---| +| **Where the policy lives** | Your repository | Stored and versioned in Kosli | +| **Where it runs** | Your CI runner | Kosli (isolated from the API) | +| **What Kosli stores** | Whatever you attest back (self-reported) | A decision Kosli computed and can vouch for | +| **Data movement** | Full trail evidence is fetched to the runner | Evaluated in place — nothing shipped to the client | +| **Replicability** | Depends on your pipeline | Decision cites its exact policy version and inputs | + +Because Kosli both stores the policy and runs it, a decision points at *exactly* what ran — the +policy version and its content digest, its parameters, and the trail facts that were in scope. The +record is complete enough to **reconstruct the evaluation**, which is what makes the decision +trustworthy for audit: you can take a decision, export what it was given, and run the policy again +yourself — see [Re-run a decision offline](#re-run-a-decision-offline). + +Running evaluation next to the data is also faster and scales further: Kosli doesn't have to +ship a trail's full evidence out to your runner before evaluating, so evaluations can cover much +larger volumes of facts in the cloud. + +## Prerequisites + + +These prerequisites describe the proposed beta experience — they are not runnable today. + + +- [Install Kosli CLI](/getting_started/install). +- [Get a Kosli API token](/getting_started/authenticating_to_kosli). +- Have at least one [Control](/tutorials/working_with_controls) defined, and a + [Flow](/getting_started/flows) with a [Trail](/getting_started/trails) carrying the + attestations you want to evaluate. + +```shell +export KOSLI_ORG= +export KOSLI_API_TOKEN= +``` + +## Author, ship, and evaluate a policy + +The core loop takes a policy from local Rego to a recorded decision. The later sections build on +it — [validating](#validate-and-test-a-policy) and [managing](#manage-policies) policies, and +[viewing](#view-the-decisions-for-a-policy) and [inspecting](#inspect-a-failing-evaluation) the +decisions they produce. + + + + + +Author your policy as code in your own repository. A policy is its **policy-as-code** (Rego), a +human-readable **name**, a short **description** of its purpose, and an **input-context schema** +declaring whether it evaluates a single trail or multiple trails. You give it a stable +**identifier** when you publish it to Kosli (next step). + +Your repository stays authoritative for **authoring** a policy — it is where the Rego is written, +reviewed, and tested. Kosli is authoritative for **what executed**: once a version is published, +that stored version is what runs and what a decision cites, so the audit record never depends on +your repository still being reachable or unchanged. + +The output schema requires an `allow` rule; you can return richer output alongside it. Follow +the same fail-safe design rules as client-side policies — see +[Evaluate trails with OPA policies](/tutorials/evaluate_trails_with_opa) for the +reasoning behind them. The full contract a policy is written against — entry point, `input`, +parameters, available built-ins, and output — is in +[The Rego authoring contract](#the-rego-authoring-contract). + +```rego pr-approved.rego +package policy + +import rego.v1 + +pr_attestation_name := data.params.pr_attestation_name + +default allow = false + +allow if { + every pr in input.trail.compliance_status.attestations_statuses[pr_attestation_name].pull_requests { + count(pr.approvers) > 0 + } +} + +violations contains msg if { + some pr in input.trail.compliance_status.attestations_statuses[pr_attestation_name].pull_requests + count(pr.approvers) == 0 + msg := sprintf("pull-request %v has no approvers", [pr.url]) +} +``` + +Before you ship it, you can [validate and test the policy](#validate-and-test-a-policy) against +real trail data. + + +In the beta, a policy evaluates the facts on the trail(s) you point it at — attestations on +`trail` / `trails`. Evaluating other targets (snapshots, artifacts, generic input) and +composing multiple policies into one are **not included** (see [What's out of scope](#whats-out-of-scope-for-the-beta)). + + + + + + +Publish the policy into Kosli via the CLI (or API). The positional argument is the policy's +**stable identifier** (`pr-approved` here) — this is what decisions and evaluations use to refer to +the policy, so it is **immutable once created**; choose one that matches how your organization +names its policies. `--name` and `--description` are the human-readable label and purpose, and +both can be changed later without changing the identifier. + +Pass a single `.rego` file, as here, or a directory of them when a policy is split into packages — +Kosli stores the whole set as one versioned bundle with one digest, so a decision still points at +one unambiguous thing. Tests are stored with the bundle but never executed server-side. + +Kosli records the first **version** on publish. Every later change creates a new version and +records who made it, so a decision can always point back to the exact policy that produced it — +see [Manage policies](#manage-policies) for updating, tagging, and archiving over time. + +**Only org admins can publish a policy.** Creating, updating, and archiving a policy all require +org admin, the same as [controls](/tutorials/working_with_controls#creating-a-control). Running an +evaluation and recording a decision does not — an ordinary API token or service account is enough, +so pipelines can invoke a policy without being able to change one. That split is what makes a +server-side decision worth more than a self-reported one: the team being audited can *run* the +control, but only an admin can decide what the control checks. + +```shell +kosli create policy pr-approved pr-approved.rego \ + --type rego \ + --context-schema trail \ + --name "Pull request approved" \ + --description "Every pull request on the trail has at least one approver." +``` + +```plaintext +Policy 'pr-approved' created + identifier: pr-approved + name: Pull request approved + version: 1 +``` + + +`kosli create policy` is the same command used for [environment policies](/getting_started/policies): +`--type rego` selects an evaluation policy, while the default `--type env` creates an environment +policy. Note that the positional argument differs between the two — for `--type env` it is the +policy **name**, while for `--type rego` it is the immutable **identifier** described above. In the +beta, authoring is **CLI/API only** — the Kosli UI is read/tag-only. + + + + + + +`kosli evaluate policy` is the core command. Kosli fetches the in-scope facts, runs the policy +**server-side**, and records a **decision** against a control. Evaluation is **asynchronous by +default** — the command returns once the evaluation is queued. Add `--sync` to wait for and +return the outcome, as shown here. + +```shell +kosli evaluate policy \ + --flow "$FLOW_NAME" \ + --policy pr-approved \ + --control RCTL-043 \ + --context trail="$TRAIL_NAME" \ + --fingerprint "$ARTIFACT_FINGERPRINT" \ + --name pr-approval-decision \ + --params '{"pr_attestation_name": "pull-request"}' \ + --sync +``` + +```plaintext +RESULT: ALLOWED +DECISION: recorded against control RCTL-043 +POLICY: pr-approved @ version 1 +``` + +The four flags that carry the meaning: + +| Flag | Description | +|------|-------------| +| `--policy` | The policy to run — a Kosli policy **identifier** (uses its current version) or a local `.rego` file. | +| `--control` | The control identifier the resulting decision is recorded against. | +| `--context` | What it runs against — the **source**. Defaults to the decision trail. | +| `--flow` / `--trail` | Where the decision is recorded — the **destination**. | + + +These flags pull in opposite directions, so it is worth reading them slowly. `--context` chooses +the trail whose facts are **read**. `--flow` and `--trail` choose the trail the decision is +**written** to. Most of the time all three name the same trail; when they don't, that is a +deliberate choice — see the *Evaluating facts from another flow* example below. + + +By default the command queues the evaluation and returns without waiting; the decision is +recorded when the evaluation completes. Add `--sync` to wait for the outcome, or `--assert` to +both wait and fail the step when the policy denies — use `--assert` as a pipeline gate. Once +recorded, a decision appears against the policy — see +[View the decisions for a policy](#view-the-decisions-for-a-policy). + + + +| Flag | Description | +|------|-------------| +| `--flow` | The flow the decision is recorded on (the **destination** flow). | +| `--trail` | The trail the decision is recorded on. Defaults to the evaluation trail when a single one is evaluated; required otherwise. | +| `--policy` | The policy to evaluate — a Kosli policy **identifier** (uses its current version) or a **local** `.rego` file (see [Evaluate a local policy file](#evaluate-a-local-policy-file)). | +| `--control` | The control identifier the resulting decision is recorded against. | +| `--context` | One or more **typed references** to what should be evaluated (the **source**), repeatable. This is what Kosli resolves into the [evaluation context](#re-run-a-decision-offline) a decision cites — not a cluster or account selector. In the beta the only reference *type* is a trail: `trail=` (in `--flow`), or `trail=/` to name a trail in another flow. Repeat the flag to evaluate several trails — see [Evaluating multiple trails](#evaluating-multiple-trails). Defaults to the decision trail. The typed form leaves room for other context types — snapshots and reported workloads, for example — later, without new flags. | +| `--fingerprint` / `--artifact-type` | The artifact the decision applies to. Pass `--fingerprint` directly, or `--artifact-type` with the artifact name/path so Kosli calculates the fingerprint. | +| `--name` | The attestation slot name the decision is recorded under on the trail. A decision recorded here satisfies a `type: decision` attestation in the trail's [flow template](/template-reference/flow_template) when the names match — see [Requiring decisions in flow templates](/tutorials/working_with_controls#requiring-decisions-in-flow-templates). | +| `--params` | Parameters passed to the policy as `data.params`. Validated against the parameters the policy reads before the evaluation is queued — an unknown or missing name is an error. Every evaluation records the params it used. | +| `--description` | Optional human-readable context for the decision. | +| `--annotate` | Optional `key=value` annotations stored with the decision. | +| `--sync` | Wait for the evaluation to finish and return the outcome. Off by default — evaluation runs asynchronously. | +| `--assert` | Exit non-zero when the policy denies, for use as a pipeline gate. Implies `--sync`, since asserting requires the outcome. | +| `--dry-run` | Evaluate and return the outcome **without recording a decision** — for testing (see [Run an ad-hoc evaluation](#run-an-ad-hoc-evaluation)). | + + + +**Evaluating facts from another flow.** By default the decision is recorded on the same trail +whose facts you evaluate. Because each `--context` reference carries its own flow +(`trail=/`), you can evaluate a trail in a *different* flow — for example, +evaluating a build flow's trail but recording the decision on a release flow's trail: + +```shell +kosli evaluate policy \ + --policy pr-approved \ + --control RCTL-043 \ + --context trail=build-ci/build-42 \ + --flow release \ + --trail release-99 \ + --fingerprint "$ARTIFACT_FINGERPRINT" \ + --name pr-approval-decision \ + --sync +``` + +The decision records which flow and trail the facts came from, so a reviewer can always see that a +release decision was reached from a build flow's evidence rather than from the release trail itself. + + +**Be deliberate about which flows may satisfy a production control.** Any flow you can write to can +supply the facts for a decision on any other flow, and the beta does not check that a given policy +or source flow is acceptable for a given control. That makes it possible for evidence from a +low-assurance flow — a sandbox or an experiment — to close a control that gates production. Until +Kosli can constrain this, treat the set of flows that feed production controls as something to +decide and review, not something to leave open. + + + +**The server captures the evidence for you.** Because Kosli runs the evaluation, the decision +automatically records the policy (and its version), the evaluation report, and any violations — +you don't attach them by hand with `--attachments` / `--user-data` as you do today when you +attest a client-side result yourself. Likewise, `--compliant` is **derived** from the policy's +`allow` result rather than passed in. This is the core difference from +[recording a decision yourself](/tutorials/working_with_controls#recording-a-decision-against-a-control). + + + +The decision this records is the same kind of decision described in +[Working with controls](/tutorials/working_with_controls) — the difference is that Kosli +computed it server-side rather than you attesting the outcome yourself. **Enforcement is +unchanged:** require the control via `for_control` in an [environment policy](/getting_started/policies) +and gate promotion with [`kosli assert artifact --environment`](/getting_started/enforce_policies), +exactly as before. Recording the decision is the PDP; asserting it is the PEP. + + + + + + +As soon as the evaluation completes, the decision appears in Kosli against the control and the +policy that produced it. Open it in the [Kosli app](https://app.kosli.com) to see the outcome, and +the **evaluation context** it was reached from: + +| Recorded | Why it is there | +|---|---| +| The **evaluation context** the decision came from | The context holds what the evaluation was given. Naming it is what makes the decision traceable — and [re-runnable](#re-run-a-decision-offline). | +| The policy **version**, and a **digest** of the exact policy source that ran | A version number says which release; a digest proves the bytes. Together they mean nobody has to trust that version 3 is still what version 3 was. | +| The **parameters** used | Parameters are the only outside input, so a decision is not interpretable without them. | +| The trail **moments** in scope, and the **attestation IDs** actually used | A policy may ignore half a trail, so the context names the facts it actually relied on. | +| The **evaluation engine version** | The same policy on a different engine is not guaranteed to be the same evaluation. | +| The **requested** and **recorded** times | See below. | + +**A decision carries two timestamps.** The **requested** time is when the evaluation was asked +for; the **recorded** time is when the decision was written, once the evaluation came off the +queue. A decision is **valid as of the requested time**, even though it was recorded later — the +same distinction as a trade executed at one time and booked at another. Kosli records both and +never backdates the record. + +This matters when facts arrive mid-flight: an attestation added between the two timestamps is +*not* part of the decision, because the trail moment is anchored to the requested time. If that +new fact changes the answer, it takes a new evaluation to say so. + +Because the policy version, parameters, and trail moments are all pinned to that requested time, +the decision states exactly what was evaluated and when it applied — enough to +[re-run it offline](#re-run-a-decision-offline). To review and filter every decision a policy has +produced, see [View the decisions for a policy](#view-the-decisions-for-a-policy). + + + + + +### Evaluating multiple trails + +A policy published with `--context-schema trails` evaluates several trails at once and receives +them as `input.trails`. Repeat `--context` to name each one: + +```shell +kosli evaluate policy \ + --flow release \ + --trail release-99 \ + --policy pr-approved-across-builds \ + --control RCTL-043 \ + --context trail=build-ci/build-41 \ + --context trail=build-ci/build-42 \ + --fingerprint "$ARTIFACT_FINGERPRINT" \ + --name pr-approval-decision \ + --sync +``` + +The context schema and the number of references have to agree: passing several references to a +`trail`-schema policy is rejected before the evaluation is queued, as is passing one reference to a +`trails`-schema policy. Because there is no single evaluation trail to default to, `--trail` is +required to say where the decision is recorded. + +## The Rego authoring contract + +Kosli evaluates your policy with OPA, so the tooling you already use locally — `opa fmt`, +`opa check`, `opa test` — works on these policies unchanged. What Kosli fixes is the contract +around the policy: the entry point it evaluates, the shape of `input`, the parameters and +built-ins a policy may use, and the output Kosli reads back. + + +Everything in this section is **proposed and open for comment**. If a constraint here would stop +you shipping a control you need, that is the most useful feedback you can give us. + + +### Entry point + +A policy declares `package policy`, and Kosli evaluates `data.policy.allow`. Keeping the package +fixed means the CLI, the server, and your own `opa test` cases all agree on where the entry point +is, and it is what lets your tests import the policy under test as `policy.allow`. + +### What the policy sees in `input` + +A policy evaluates the **attestations in the slots on the trail** — nothing else. Trail events +(`trail started`, `trail updated`) are not attestations and are not in `input`, and neither are +attestations from earlier moments of the same trail. Slots are matched **by name**, not by +attestation type. + +The `--context-schema` you declare when you publish the policy selects the shape of `input`: + +| `--context-schema` | `input` contains | Reference schema | +|---|---|---| +| `trail` | `input.trail` — a single trail object | `https://docs.kosli.com/schemas/evaluation-input/trail/v1.json` | +| `trails` | `input.trails` — an array of trail objects | `https://docs.kosli.com/schemas/evaluation-input/trails/v1.json` | + +Those schemas are published as JSON Schema so you can type-check a policy against `input` before +you ship it. Associate one with your rules using an OPA +[metadata annotation](https://www.openpolicyagent.org/docs/policy-language#metadata): + +```rego pr-approved.rego +package policy + +import rego.v1 + +# METADATA +# scope: document +# schemas: +# - input: schema["trail-v1"] +default allow := false +``` + +Then check the policy against it. `opa check` resolves `schema["trail-v1"]` to `trail-v1.json` in +the directory you pass to `--schema`: + +```shell +mkdir -p schemas +curl -so schemas/trail-v1.json https://docs.kosli.com/schemas/evaluation-input/trail/v1.json + +opa check --strict --schema schemas pr-approved.rego +``` + +This turns a misspelled field into a compile error naming the fields that do exist, rather than an +undefined reference that silently leaves `allow` at its `false` default. `--strict` additionally +rejects unused imports and unused local assignments. + + +Schemas give you **type checking while authoring**; they are not how Kosli validates a policy on +publish. Kosli performs its own validation — see +[Validate and test a policy](#validate-and-test-a-policy). + + +### Parameters + +Parameters are the only input to a policy from outside the trail. They arrive as `data.params`, +and every evaluation records the parameters it used, so a decision always shows what the policy +was run with. + +```rego +pr_attestation_name := data.params.pr_attestation_name +``` + +Alias parameters at the top of the policy, as above, so a missing parameter leaves the alias +undefined and any rule referencing it fails to evaluate — rather than the policy proceeding with a +silently absent value. + +You don't declare the parameters a policy takes. Kosli **derives** them from the policy itself, by +reading which `data.params` paths the Rego depends on, and **validates the parameters you supply at +evaluation time** — an unknown parameter name or a missing one is rejected before the evaluation is +queued, by the CLI where it can be caught locally and by the server as the actual gate. + +This closes off a failure that is otherwise very hard to spot. Supplying `maxHigh` to a policy that +reads `max_high` leaves the alias undefined, so `allow` falls back to `false` *and* the `violations` +rules that reference the same alias don't fire either — the result is a recorded non-compliant +decision with no violations explaining it, and a failed pipeline with nothing to fix. Rejecting the +parameters up front turns that into an immediate, obvious error. + + +Derivation reads parameter paths statically, so a policy that computes parameter names at +evaluation time — `data.params[key]` for some derived `key` — cannot be checked this way, and Kosli +falls back to accepting whatever you supply. Prefer literal parameter names. + + + +Parameters are recorded on every decision and are visible to anyone who can read that decision. +Pass thresholds, names, and flags — never secrets or personal data. + + +### Output + +| Field | Required | Purpose | +|---|---|---| +| `allow` | **Yes** | Boolean. The decision outcome — this is what `--compliant` is derived from. | +| `violations` | No | A list of strings, each naming one thing that failed. Rendered on the decision in the Kosli UI and as markdown under `--assert`. | +| Any other field | No | Captured on the decision as-is, for richer human-readable context. | + +Give `allow` a `default allow := false` and drive it through positive assertions, so an +evaluation that goes wrong denies rather than allows. + +### Available built-ins + +Because Kosli executes the policy, evaluation runs with **no network access** and must produce the +same decision every time it is re-evaluated. Both constraints are expressed as an OPA +[capabilities](https://www.openpolicyagent.org/docs/cli#capabilities) file, which you can check a +policy against locally before publishing: + +```shell +curl -so kosli-capabilities.json https://docs.kosli.com/rego/capabilities/v1.json +opa check --capabilities kosli-capabilities.json pr-approved.rego +``` + +| Built-ins | Status | Reason | +|---|---|---| +| `http.send`, `net.lookup_ip_addr` | Not available | Evaluation has no network access. | +| `time.now_ns`, `rand.intn`, `opa.runtime` | Not available | Non-deterministic — see [Evaluation time](#evaluation-time) for time windows. | +| `io.jwt.verify_*`, `io.jwt.encode_sign*` | Not available | Require key material. `io.jwt.decode` is available. | +| `crypto.sha256`, `crypto.hmac.*`, and the other pure functions | Available | Deterministic, no host access. | +| Everything else | Available | The rest of the OPA built-in set. | + +### Evaluation time + +A policy cannot read the clock, because a decision that depends on when it happened to run is not +replicable. Instead, Kosli injects the evaluation context's request timestamp — the moment the +decision is **valid as of** — as `data.kosli.evaluation_time`, in nanoseconds since the Unix +epoch, so it can be compared directly against a parsed attestation timestamp: + +```rego +# 30 days before the moment this evaluation was requested. +cutoff := data.kosli.evaluation_time - time.parse_duration_ns("720h") + +approval_is_current if { + time.parse_rfc3339_ns(approval_timestamp) > cutoff +} +``` + +Here `approval_timestamp` is whichever timestamp the attestation you are evaluating carries — the +field depends on the attestation type. Time windows work as normal, they are anchored to the moment +the decision applies to, and re-evaluating the stored context produces the same answer. + +### Engine and Rego version + +Kosli pins the OPA version it evaluates with and records it on every decision alongside the policy +version, so a decision states exactly what ran. Author against that version locally and check with +`opa check --v1-compatible` to avoid a policy that compiles on your machine but not in Kosli. + +{/* TODO: state the pinned OPA version once the evaluation runtime is chosen. */} + +### When evaluation itself fails + +A policy that times out, exhausts its memory budget, or hits a runtime error is a *broken policy*, +not a non-compliant artifact, and Kosli reports the two differently — a failed evaluation records +a typed error rather than a `--compliant=false` decision, and `--assert` exits with a distinct +code so a pipeline can tell "this change is not compliant" from "this policy is broken". + +{/* TODO: confirm the exit codes and whether a failed evaluation records a decision at all. */} + +Open questions we would particularly like feedback on: whether a built-in error should abort the +evaluation or evaluate to undefined (and therefore deny), and whether `print` and `trace` output +should be discarded or captured on the evaluation report. + +## Validate and test a policy + +Before you ship a policy — or wire it into a pipeline gate — check that it holds up. + + +A policy is code, so treat it like code: keep **Rego tests alongside each policy** that assert it +allows and denies the cases you expect. Rego's built-in +[test framework](https://www.openpolicyagent.org/docs/policy-testing) runs these locally and in CI, +catching regressions before you publish a new version. + + +### Validate + +`kosli validate policy` checks that a policy is well-formed without recording anything. It +confirms that: + +- the Rego **compiles**; +- it matches its declared **input-context schema** (`trail` / `trails`); +- it satisfies the **output schema** — an `allow` rule is mandatory. + +```shell +kosli validate policy \ + --policy pr-approved.rego \ + --context-schema trail +``` + +```plaintext +RESULT: VALID +``` + +### Run an ad-hoc evaluation + +To see how a policy behaves against real trail data *without* recording a decision, add +`--dry-run` to `kosli evaluate policy`. Kosli runs the policy server-side and returns the outcome, +but no decision is stored — so `--control` and `--name` aren't needed: + +```shell +kosli evaluate policy \ + --flow "$FLOW_NAME" \ + --policy pr-approved.rego \ + --context trail="$TRAIL_NAME" \ + --params '{"pr_attestation_name": "pull-request"}' \ + --sync \ + --dry-run +``` + +```plaintext +RESULT: ALLOWED (dry-run — no decision recorded) +``` + +This is the fastest way to iterate: point a candidate policy at a trail you know should pass (or +fail) and confirm the outcome before publishing a version or gating a pipeline on it. + +### Evaluate a local policy file + +You don't have to publish a policy before it can record a decision. Point `--policy` at a local, +unpublished `.rego` file and Kosli evaluates it server-side and records the decision, just as it +would for a stored policy — useful while you're still iterating on a policy you haven't shipped: + +```shell +kosli evaluate policy \ + --flow "$FLOW_NAME" \ + --policy ./pr-approved.rego \ + --control RCTL-043 \ + --context trail="$TRAIL_NAME" \ + --fingerprint "$ARTIFACT_FINGERPRINT" \ + --name pr-approval-decision \ + --params '{"pr_attestation_name": "pull-request"}' +``` + +Because there is no stored policy version to cite, the decision records the exact policy source +that was evaluated instead. Unlike the ad-hoc evaluation above, this **records** a decision — add +`--dry-run` if you only want the outcome without recording one. + + +A decision made this way did **not** go through the org-admin gate on +[publishing a policy](#author-ship-and-evaluate-a-policy) — anyone who can record a decision can +supply the Rego that produced it. Treat these as decisions for iterating on a policy, not as +audit evidence, and publish a version before a control depends on the outcome. We would like +feedback on whether the beta should mark such decisions distinctly, or restrict them entirely. + + +## Manage policies + +### Update a policy + +Publishing a change is the same command as the initial publish: re-run `kosli create policy` with +the updated Rego, name, or description. Each publish creates a **new version** and records who made +it — add `--comment` to note what changed — so a decision always points back to the exact version +that produced it. The identifier never changes. + +```shell +kosli create policy pr-approved pr-approved.rego \ + --type rego \ + --context-schema trail \ + --name "Pull request approved by a code owner" \ + --description "Every pull request on the trail has an approver from a code owner." \ + --comment "Require approval from a code owner" +``` + +An evaluation that references a policy by identifier uses its **current version**. Decisions +recorded earlier keep pointing at the version that was current when they were made. + +**Publishing an unchanged policy is a no-op.** If what you publish is identical to the current +version, Kosli creates no new version, says so, and exits `0`: + +```plaintext +Policy 'pr-approved' unchanged — still at version 4 +``` + +This is what makes publishing safe to run on every merge, or from `terraform apply`: the command is +idempotent, so a pipeline that republishes an untouched policy leaves the history alone instead of +filling it with identical versions. + +What counts as a change: + +| Publish | Result | +|---|---| +| Same policy source, same name, description, and context schema | **No-op.** No version created. | +| Any change to the policy source | New version. The digest is over the bytes, so reformatting counts. | +| Same source, changed name, description, or context schema | New version — the description is part of what an auditor reads off a version. | +| Only `--comment` differs | **No-op.** A comment describes a change; with no change there is nothing to describe. | + +Comparison is against the **current** version, not the whole history. So republishing the bundle +from an earlier version is a real change and does create a new version — that is how you roll back, +and the history records that the rollback happened rather than hiding it. + +### Review a policy change + +A policy is the code that decides whether a control passes, so a change to it is a change to your +control environment. Reviewing that change is part of the lifecycle — author, test, version, +**review**, ship — and in the beta the review happens in your policy repository, not in Kosli. + +The practice we recommend, and the one Kosli's records are designed to corroborate: + + + + +Require pull requests, and require review from the people accountable for the control — a +`CODEOWNERS` entry per policy is the simplest way to make sure the right control owner sees a change +to *their* control. Restrict who can push directly. + + + +The question a control owner has to answer is not "is this valid Rego?" but "does this policy +actually meet the control objective?" Reviewing the change alongside its +[tests](#validate-and-test-a-policy) is what makes that answerable — the tests state, in cases, what +the policy is claiming to enforce. + + + +Because [only org admins can publish](#author-ship-and-evaluate-a-policy), publish from a CI job +running with an admin credential on merge, rather than by hand from a laptop. The reviewed commit +then becomes the published version, and the two cannot drift. + +You can publish every policy on every merge without thinking about which ones changed — +[publishing an unchanged policy is a no-op](#update-a-policy), so only the policies you actually +edited get a new version. + + + +Use `--comment` to reference the change record, so Kosli's version history and your review history +join up: + +```shell +kosli create policy pr-approved pr-approved.rego \ + --type rego \ + --context-schema trail \ + --comment "Require approval from a code owner (acme/policies#214)" +``` + + + + +Kosli records who published each version, when, what they said changed, and the digest of the source +that was published. Combined with the review in your repository, that is a complete change-control +trail for the policy itself — which is what an auditor asking "who decided this is what the control +means?" is looking for. + + +There is **no review or approval step inside Kosli** in the beta: a policy version is live as soon as +it is published, and there is no draft state or second-approver requirement. If your control +environment needs approval to be enforced by Kosli rather than by your repository, tell us — it +affects how much of this belongs in the product. + + +### Tag a policy + +Tags are versionless labels for organizing and filtering policies — grouping by team, framework, +or control area. Tagging does **not** create a new version. + +```shell +kosli tag policy pr-approved --set team=platform --set framework=soc2 +``` + +### Archive and unarchive + +When a policy is retired, archive it rather than deleting it. Archiving is light-touch: the policy +is removed from the main list in the UI but **remains evaluable**, and its versions and past +decisions are preserved. Archiving records who archived it and when, and does not create a new +version. Unarchive it if it comes back into use. + +```shell +kosli archive policy pr-approved +kosli unarchive policy pr-approved +``` + +### List and filter policies + +Browse your policies in the [Kosli app](https://app.kosli.com) — select **Policies** in the +sidebar to see the full catalog. Search by name or identifier, and filter by tag to narrow the +list. Each policy shows its identifier, current version, and tags. + + + Policies catalog listing policies with their identifier, current version, and tags, with a search box and a tag filter + + +You can also list policies from the CLI. Filter to evaluation policies with `--type rego`, and by +name or tag — each entry shows the identifier and current version: + +```shell +kosli list policies --type rego --tag team=platform +``` + +### View a policy + +Select a policy to open it. The **Policy** tab is the default view: it shows the policy-as-code +that Kosli actually runs, read-only, for the version you choose. This is what answers the question +a control owner has to answer — *does this policy meet the control objective?* — which a name and +description can only claim. + + + Policy tab for a policy showing the bundle digest, roots, entrypoint and context schema, a file list, and the read-only Rego source of the selected file with line numbers + + +A policy can be a single `.rego` file or several, so the tab lists the files in the version and +shows the one you select. Alongside them it shows what an auditor needs to check the record: the +**bundle digest** a decision cites, the **entrypoint** that Kosli evaluates, the declared +**context schema**, and the **parameters** the policy reads. Tests shipped with the policy are +listed and marked *not executed* — Kosli stores them as evidence the policy was tested, but only +the entrypoint runs. + + +When you open a policy from a decision, the tab shows the version **that decision used**, not the +current one — so the source in front of you is the source that produced the outcome you are looking +at. + + +Policies are read-only in the Kosli UI. Edit them in your repository and publish a new version with +`kosli create policy`. + +## View the decisions for a policy + +This is where **auditors and control owners** review what a policy has decided. Every decision a +policy produces is listed against that policy, across **all versions** of the policy. In the +[Kosli app](https://app.kosli.com), select **Policies**, choose a policy, and open its +**Decisions** tab. Each row shows the timestamp, the trail and artifact the decision applies +to, the policy version that produced it, and the outcome (compliant or non-compliant). Filter by +outcome, version, or time range, and page through the results to sample them. + + + Decisions tab for a policy showing decisions with timestamp, compliant or non-compliant outcome, trail, artifact fingerprint, and policy version, with outcome and version filters + + +Select a decision to open its detail, showing the trail and artifact it applies to, the policy +version and parameters that were used, the evaluation report, and any violations. + + + Decision detail showing the trail, artifact, policy version and parameters, the evaluation report, and the list of violations + + +For an audit or an offline review, export the filtered set as CSV — from the Decisions view in the +UI, or from the CLI: + +```shell +kosli list decisions \ + --policy pr-approved \ + --compliant=false \ + --since 30d \ + --output csv > decisions.csv +``` + +`--compliant=false` surfaces every non-compliant evaluation for follow-up; combine it with +`--since` or a version filter to scope the export. + +Each row shows the requested and recorded timestamps, so an export is enough to sample decisions +by when they applied rather than only by when they were written. + + +The same filtered sampling and CSV export are proposed for a **control's** Decisions tab, so an +auditor can sample by control as well as by policy. That is not part of the +[controls](/tutorials/working_with_controls) feature today. + + +### Re-run a decision offline + +Sampling a decision usually raises the question of whether it was *right*. Everything the +evaluation was given is held on its **evaluation context**, which the decision names — so you can +export that context and evaluate the policy again yourself, with stock OPA, on your own machine: + +```shell +kosli get evaluation-context CONTEXT-ID --output fixture --dir ./replay +``` + +Every decision shows the evaluation context it came from, in the UI and in +`kosli get decision`, so the context ID is one hop from the decision you are looking at. + +The export contains what the evaluation was given, as ordinary OPA inputs: + +| File | Contents | +|---|---| +| `input.json` | The trail moment that was evaluated — `input.trail` or `input.trails`. | +| `data.json` | `params` as supplied, and the `kosli.evaluation_time` that was injected. | +| `policy.rego` | The exact policy source that ran, matching the digest on the decision. | + + +A context exists from the moment an evaluation is queued, so this also works when there is **no +decision to look at** — an evaluation still in the queue, or one that failed. That is usually the +case you most want to inspect. + + +Evaluate it with OPA directly: + +```shell +opa eval --input replay/input.json \ + --data replay/data.json \ + --data replay/policy.rego \ + 'data.policy.allow' +``` + +Because the evaluation sandbox excludes anything non-deterministic — no clock, no network, no +randomness, with time supplied as data — this returns the outcome the decision recorded. If it +doesn't, that is a finding in itself. + + +This is the strongest form of the audit story, because it doesn't ask anyone to trust Kosli to check +Kosli. An auditor can reach the same conclusion independently, with a tool Kosli doesn't control. +The same export doubles as a **regression fixture**: commit `input.json` and `data.json` beside your +policy and assert against them with `opa test`, and a real past decision becomes a permanent test +case. + + +Re-running a stored decision through a *different* policy or version — to ask "what would the new +policy have said?" — is [out of scope for the beta](#whats-out-of-scope-for-the-beta). Exporting the +fixture and running it locally is the way to answer that question today. + +## Inspect a failing evaluation + +When a policy denies, an **application team** needs to know why — and the decision records it +through its `violations`. Whether the policy's rules weren't met (for example, a pull request with +no approver) or an expected attestation was missing from the trail, both surface as violations on +the decision. + +A policy can return **more than `allow` and `violations`** — any additional fields in its output +are captured on the decision, so you can surface richer, human-readable context beyond a bare +pass/fail. + +When you gate a pipeline with `--assert`, Kosli prints the violations as markdown so failures are +readable directly in CI logs: + +```shell +kosli evaluate policy \ + --flow "$FLOW_NAME" \ + --policy pr-approved \ + --control RCTL-043 \ + --context trail="$TRAIL_NAME" \ + --fingerprint "$ARTIFACT_FINGERPRINT" \ + --name pr-approval-decision \ + --params '{"pr_attestation_name": "pull-request"}' \ + --assert +``` + +```plaintext +RESULT: DENIED +VIOLATIONS: pull-request https://github.com/acme/app/pull/42 has no approvers +``` + +In the Kosli UI, the violations are parsed out of the decision and rendered on the decision detail +(shown above under [View the decisions for a policy](#view-the-decisions-for-a-policy)), so an +application team can see exactly what to fix. + +## What's out of scope for the beta + +The following are explicitly **not included** in the beta and may be addressed later. They are +listed so you can tell what has been deferred from what has not been considered — if something here +blocks you, that is worth telling us. + +- **Evidence-attachment content evaluation** — evaluating the contents of attached files. +- **Composable policies** — composing many authored policies into one versioned policy. +- **Non-trail evaluate targets** — snapshots, artifacts, and generic input. +- **Scheduled evaluations** and **risk measurement**. +- **Associating policies to controls** — a stored policy-to-control link that lets Kosli infer + which policy closes which control. In the beta you name the control per evaluation with + `--control`; there is no stored mapping. +- **Pinning an evaluation to a policy version** — an evaluation always uses the policy's current + version. There is no way to hold a pipeline on version 3 while version 4 is rolled out. +- **Re-running a past decision against a new policy** — you cannot replay a stored evaluation + context through a different policy or version to compare outcomes. +- **Advisory outcomes** — a decision is compliant or non-compliant. There is no advisory or + warn-only mode that records a concern without failing the control. +- **Verbose evaluation tracing** — beyond `violations` and any custom output the policy returns, + there is no rule-by-rule trace of why a policy reached its result. +- **Filtering decisions by branch or environment** — decisions can be filtered by outcome, policy + version, and time, but not by where the change came from or where it was heading. + +## Next steps + +- [Working with controls](/tutorials/working_with_controls) — define controls and enforce them with `for_control` and `kosli assert`. +- [Environment policies](/getting_started/policies) — require controls before an artifact runs in an environment. +- [Enforce policies](/getting_started/enforce_policies) — gate promotion on compliance in your pipeline. +- [Evaluate trails with OPA policies](/tutorials/evaluate_trails_with_opa) — the client-side `kosli evaluate` this builds on. + +## Feedback + +This is a design preview. If you are reviewing the proposed beta flow, the most useful feedback +is on the command surface (`kosli evaluate policy` and how policies are published), +[the Rego authoring contract](#the-rego-authoring-contract) — particularly any constraint there +that would stop you shipping a control you need — the decision and audit model, and anything in +[What's out of scope](#whats-out-of-scope-for-the-beta) that you would expect in the beta. Share +comments with the Kosli team. diff --git a/tutorials/working_with_controls.mdx b/tutorials/working_with_controls.mdx index e00899a..d566afe 100644 --- a/tutorials/working_with_controls.mdx +++ b/tutorials/working_with_controls.mdx @@ -4,7 +4,7 @@ description: "Learn how to define controls in Kosli, record decisions against th --- -**This feature is in beta.** Controls, decisions, the `kosli attest decision` command, and the `for_control` policy requirement are all available in Kosli but in beta — the UI, CLI flags, and policy YAML schema may still change. It is rolled out per organization, so reach out to the Kosli team to enable it for yours, or with any questions and comments. +**This feature is in beta.** Controls, decisions, the controls CLI commands (`kosli create control`, `kosli attest decision`, and related), and the `for_control` policy requirement are all available in Kosli but in beta — the UI, CLI flags, and policy YAML schema may still change. It is rolled out per organization, so reach out to the Kosli team to enable it for yours, or with any questions and comments. Controls in Kosli represent the named, identifiable governance requirements that your organization enforces across software delivery — things like "source code review", "no hard-coded credentials", or "vulnerability scan passed". They are the things auditors ask about, the things compliance teams track, and the things governance platform engineers build automation around. @@ -15,13 +15,13 @@ This tutorial covers how to: - Define a control library in Kosli that mirrors your existing controls catalog - Record decision outcomes against controls from your pipelines -- Reference controls in environment policies -- Review the decisions and version history of each control +- Require decisions in flow templates and reference controls in environment policies +- Review the decisions, coverage, and version history of each control ## Prerequisites - [Install Kosli CLI](/getting_started/install). -- [Get a Kosli API token](/getting_started/service-accounts). +- [Get a Kosli API token](/getting_started/authenticating_to_kosli). - Have at least one [Flow](/getting_started/flows) and [Trail](/getting_started/trails) already created. ## Setup @@ -60,7 +60,22 @@ Navigate to **Controls** in the [Kosli app](https://app.kosli.com) sidebar and s | **Description** | Optional. What the control does, in human-readable terms. | | **Links** | Optional. One or more named URLs pointing back to the authoritative definition in your GRC system, ServiceNow, or policy document. | -Only org admins can create or edit controls. **Tags** are added separately, from a control's detail page after it exists — they're not part of the create form and don't create a new version. Controls can also be managed programmatically through the controls API (`POST /api/v2/controls/{org}`). +Only org admins can create or edit controls. **Tags** are added separately — from a control's detail page after it exists, or from the CLI with `kosli tag control CONTROL-IDENTIFIER --set key=value`. They're not part of the create form and don't create a new version. + +Controls can also be managed from the command line, or programmatically through the controls API (`POST /api/v2/controls/{org}`). Create a control with `kosli create control` — `--name` is required and `--description` is optional: + +```bash +kosli create control RCTL-043 \ + --name "Source code review" \ + --description "All changes are reviewed by a second developer before release." +``` + +Set or change a control's name, description, or links with `kosli update control`. Each update creates a new version: + +```bash +kosli update control RCTL-043 \ + --description "All commits are reviewed by a second developer before release." +``` Control identifiers are the stable identity that decisions and environment policies reference. Choose identifiers that match how your organization already refers to controls — for example, the identifiers in your ServiceNow or GRC system. If you use `RCTL-043` today, use exactly that. @@ -76,7 +91,13 @@ Control definitions are versioned: each time you update a control's name, descri Controls catalog listing controls with their identifier, version, name, description, and tags, with a search box and tag-filter and sort controls -You can also list controls through the controls API (`GET /api/v2/controls/{org}`). +You can also list controls from the CLI with `kosli list controls`, fetch a single control with `kosli get control CONTROL-IDENTIFIER`, or use the controls API (`GET /api/v2/controls/{org}`). + +### Archiving a control + +When a control is retired, archive it rather than deleting it. Archiving moves the control out of the main catalog into its **Archived** view but preserves its full history — past decisions and every version of the definition remain intact for audit — and an archived control can be unarchived later if it comes back into use. + +Archive or unarchive a control from its detail page (org admins only), from the CLI with `kosli archive control CONTROL-IDENTIFIER` and `kosli unarchive control CONTROL-IDENTIFIER`, or through the controls API (`POST /api/v2/controls/{org}/{identifier}/archive` and `.../unarchive`). Archiving and unarchiving are recorded as events with the actor and time, and do not create a new version of the definition. Both appear in the control's **Versions** tab alongside Created and Edited events, so the full lifecycle is auditable in one place. ## Recording a decision against a control @@ -149,6 +170,23 @@ This creates a decision attestation with: - **`--user-data`** containing the violations, which appear in the Kosli UI as structured metadata on the attestation - **`--compliant`** set directly from the evaluation result +## Requiring decisions in flow templates + +A [flow template](/template-reference/flow_template) defines which attestations a trail must have to be compliant. Because a decision is an attestation, you can make one a required part of a trail by adding an attestation of `type: decision`. The `name` must match the `--name` you record the decision under. + +```yaml release-template.yml +# yaml-language-server: $schema=https://docs.kosli.com/schemas/flow-template/v1.json +version: 1 +trail: + attestations: + - name: source-code-review-decision + type: decision +``` + +With this template, a trail is only compliant once a decision attestation named `source-code-review-decision` has been recorded on it. Use this when the decision is part of the trail's own definition of done — for example, a release trail that must carry a recorded judgment before it is considered complete. + +Flow templates require _that a decision was recorded_; they do not tie it to a specific control identifier. To require a decision for a **named control**, use `for_control` in an [environment policy](#referencing-controls-in-environment-policies) instead. The two mechanisms complement each other: the flow template gates trail compliance, the environment policy gates what may run in an environment. + ## Asserting artifact compliance `kosli assert artifact --environment` is the Policy Enforcement Point (PEP). It checks that an artifact satisfies all requirements in the environment's attached policy — including any controls required via `for_control` rules — and exits non-zero if it doesn't. Use it as a pipeline gate before promoting an artifact to an environment. @@ -230,11 +268,11 @@ For the complete policy syntax, including attestation requirements and exception ## Viewing a control -Navigate to **Controls** in the [Kosli app](https://app.kosli.com) and select a control to see its detail view. The header shows the identifier, current version, name, any links, description, and tags. Below it are two tabs: **Decisions** and **Versions**. +Navigate to **Controls** in the [Kosli app](https://app.kosli.com) and select a control to see its detail view. The header shows the identifier, current version, name, any links, description, and tags. Below it are three tabs: **Decisions**, **Coverage**, and **Versions**. ### Decisions -The Decisions tab is the default view. It lists every decision recorded against this control across all flows and trails, with the timestamp, the trail it was recorded on, the artifact fingerprint, and the outcome (compliant or non-compliant). Filter by outcome and date range to narrow the list. +The Decisions tab is the default view. It lists every decision recorded against this control across all flows and trails, with the timestamp, the trail it was recorded on, the artifact fingerprint, and the outcome (compliant or non-compliant). Filter by outcome and flow, and sort by timestamp, to narrow the list. Decisions tab for a control showing decisions with timestamp, compliant/non-compliant outcome, trail, and artifact fingerprint @@ -243,16 +281,32 @@ The Decisions tab is the default view. It lists every decision recorded against Select a decision to open its detail, showing the trail and artifact it applies to, the origin URL, the user-data behind the judgment (such as the Rego evaluation result and the parameters it was run with), and any attached evidence files (such as the Rego policy). - Decision detail tray showing the trail, artifact, origin URL, user-data, and attached evidence file for a compliant decision + Decision detail tray showing the trail, artifact, origin URL, user-data, and attached evidence file for a decision + + +### Coverage + +The Coverage tab shows how widely this control is enforced across your environments. A control is **covered** in an environment when that environment has an active [policy](/getting_started/policies) whose latest version references this control through a `for_control` requirement — regardless of whether the policy is currently passing. Coverage measures _enforcement reach_, not compliance: an environment whose policy references the control but is currently failing still counts as covered, and the policy name is shown so you can investigate. + +At the top, a progress bar summarizes coverage as a percentage and as `N of M environments`. Below it, each environment is listed with the policy that covers it and a **Covered** or **Not covered** badge. Filter the list by environment name, and use the **Status** filter to show all environments, only covered, or only not covered — handy for focusing on enforcement gaps. + + + Coverage tab for a control showing a coverage progress bar, a Status filter, and a list of environments each with the policy that covers it and a covered or not-covered badge + + **Archived environments are excluded** from the coverage list and from the totals — they are not counted in `N of M`. Only active policies contribute to coverage: if a policy is archived or detached from an environment, that environment is no longer covered by it. + + +Coverage is also available through the controls API: `GET /api/v2/controls/{org}/{identifier}/coverage` returns the summary (`total`, `covered`, `coverage_percent`) and a paginated list of environments, with name search and filtering by coverage state (`?covered=true` / `?covered=false`). + ### Versions The Versions tab lists every version of the control definition — the name, description, and links as they were at each version, with who made the change and when. Because every decision references the control version that was current when it was recorded, this history lets auditors see exactly what the control required at the time of each decision. ## What you've accomplished -You have learned how to define controls in Kosli, record decisions against them from pipelines using `kosli attest decision` as the PDP, require controls in environment policies with `for_control`, assert artifact compliance using `kosli assert artifact --environment` as the PEP, and review the decisions and version history of each control. +You have learned how to define controls in Kosli, record decisions against them from pipelines using `kosli attest decision` as the PDP, require controls in environment policies with `for_control`, assert artifact compliance using `kosli assert artifact --environment` as the PEP, and review the decisions, coverage, and version history of each control. Your controls catalog is now the bridge between the evidence Kosli collects and the requirements your auditors, control owners, and regulators care about. For each production change, you can now answer: "which of our controls have a compliant decision recorded, and which don't?"