Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ package deploy
import data.ergo

requirements := {"prod_deploy": {
"description": "Every production deployment is approved",
"subject_type": "deployment",
"from": ["deployments"],
"id": ["id"],
Expand All @@ -45,7 +46,7 @@ report := ergo.report(input, requirements)
violations := ergo.violations(report)
```

A requirement describes what to check, not how to check it. It starts by saying what the policy is about, the things we call _subjects_:
A requirement describes what to check, not how to check it. Its `description` says what it requires, in words anyone can read. Then it says what the policy is about, the things we call _subjects_:

- `subject_type` is a human-friendly name for them.
- `from` is the path to the subjects in the input. `["deployments"]` means `input.deployments`, and `["release", "deployments"]` would mean `input.release.deployments`.
Expand All @@ -68,6 +69,7 @@ Requirements are plain data, so you can keep them in a YAML file instead. OPA re
```yaml
requirements:
prod_deploy:
description: Every production deployment is approved
subject_type: deployment
from: [deployments]
id: [id]
Expand Down Expand Up @@ -130,7 +132,7 @@ The report records every check ergo ran, including the ones that passed. It come
```

- `compliant` is the overall answer. When you need a plain yes or no to allow or block something, this is the one to use: `allow := report.compliant`.
- `requirements` has an entry for each requirement. Its `status` is `met`, `not_met`, or `not_applicable` when no subject was left to check. It also says how many subjects were found and kept, and lists every check with a readable `expression`, such as `approved_by is a non-empty string`.
- `requirements` has an entry for each requirement. It repeats the requirement's `description`, and its `status` is `met`, `not_met`, or `not_applicable` when no subject was left to check. It also says how many subjects were found and kept, and lists every check with a readable `expression`, such as `approved_by is a non-empty string`.
- `results` has one row per subject and check, with the value ergo read, whether the check passed, and a `cause`.

Here's what those rows look like for our example:
Expand Down
29 changes: 22 additions & 7 deletions REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ From your own policies, call only `ergo.report`, `ergo.report_with_params` and `

```rego
{
"description": "Every production deployment is approved",
"meta": {"control": "SDLC-CTRL-0007", "frameworks": ["SOC 2"]},
"subject_type": "deployment",
"from": ["deployments"],
"id": ["id"],
Expand All @@ -62,6 +64,8 @@ From your own policies, call only `ergo.report`, `ergo.report_with_params` and `

| Field | Meaning | Default |
| -------------- | ----------------------------------------------------------------------------------------------------- | ----------------- |
| `description` | What the requirement asks for, in words anyone can read. It's copied into the report. | `""` |
| `meta` | Anything else you want to keep with the requirement, like a control id or an owner. It's copied into the report. ergo never reads it. | `{}` |
| `subject_type` | A name for the kind of thing being checked. It appears in every row. | `"subject"` |
| `from` | The [path](#paths) to the subjects in the input. It can end with a [naming step](#naming-subjects). | the whole input |
| `id` | The path, inside one subject, to the value that identifies it. | the whole subject |
Expand All @@ -72,6 +76,7 @@ From your own policies, call only `ergo.report`, `ergo.report_with_params` and `

A few details:

- `meta` can hold anything JSON can, like `{"control": "SDLC-CTRL-0007", "frameworks": ["SOC 2", "ISO 27001"], "version": 3, "reviewed": true}`. Its numbers follow the same rule as numbers anywhere else in a policy: one a 64-bit float can't hold, like `1e400`, makes the requirement [written wrong](#basic-operators). So does a key that isn't a string, or a set, which a policy written in Rego can hold but JSON can't. In the report, a number is written in its plain form, so `1.50`, `1e2` and `-0` come out as `1.5`, `100` and `0`, and the same `meta` gives the same report however it was written.
- If `from` leads to a list, each item is a subject. If it leads to a single object, that object is the only subject. Anything else (nothing, a string, a number) gives no subjects at all, and `$min_subjects` fails.
- If `id` doesn't lead anywhere, the subject's id is `null`. Its rows are still there.
- No two subjects can share an id, or their rows could be identical and the report couldn't say which one failed. If two do, `$unique_ids` fails and so does the requirement. Every subject counts, even one that `applies_to` leaves out, and `null` is an id like any other, so two subjects without one clash.
Expand All @@ -81,7 +86,7 @@ A few details:
- Under `some`, one subject has to pass all the checks by itself. Two subjects that each pass half of them don't count.
- A requirement with no checks, a `require` other than `every` or `some`, a badly written [naming step](#naming-subjects), an `id` whose selector has a ref or `literal` deeper inside a `where` value than ergo reads, or a check that's [written wrong](#basic-operators), is never met. The `$well_formed` row says so, and says [what's wrong](#checks-ergo-adds), like `{"name": "require", "value": ["neither every nor some"]}`.
- So is a requirement with a field that isn't in the table above, like `subject` or `requires`, because ergo would otherwise ignore it and check something you didn't mean. The `$well_formed` row lists each one in order and says what's wrong with it, the way it does for a [check that's written wrong](#checks-ergo-adds), like `{"name": "subject", "value": ["unknown field"]}`. Rows for the subjects are still there.
- So is a requirement that isn't an object, or whose `checks` or `applies_to` isn't an object, whose `from` or `id` isn't a list, whose `min_subjects` isn't a whole number of 0 or more, or whose `subject_type` isn't a string with something besides whitespace in it, since rows and descriptions name the subject by it. `null` counts as the wrong type. `2.0` is a whole number, but `-1` and `0.5` aren't. It still has its entry in `requirements` and its `$well_formed` row, which shows each such field and what it should be, like `{"name": "from", "value": ["not a list"]}`, or the whole requirement and its value, like `{"name": "requirement", "value": 5}`. ergo then reads `checks` as empty and `applies_to` as a filter it can't read, so every subject fails `$applies` with cause `absent`, shown as `<invalid applies_to>`, and gets no other rows. It reads a `from` as giving no subjects (shown as `<invalid from>`), and an `id` as giving the id `null`. A requirement that isn't an object gives no subjects either. A `min_subjects` that isn't a number fails `$min_subjects` too.
- So is a requirement that isn't an object, or whose `checks` or `applies_to` isn't an object, whose `from` or `id` isn't a list, whose `min_subjects` isn't a whole number of 0 or more, whose `subject_type` isn't a string with something besides whitespace in it, since rows and descriptions name the subject by it, whose `description` isn't a string, or whose `meta` isn't an object. `null` counts as the wrong type, except for `description` and `meta`: ergo only copies those, so a `null` one counts as not written, which is what YAML gives for an empty `description:`. `2.0` is a whole number, but `-1` and `0.5` aren't. It still has its entry in `requirements` and its `$well_formed` row, which shows each such field and what it should be, like `{"name": "from", "value": ["not a list"]}`, or the whole requirement and its value, like `{"name": "requirement", "value": 5}`. ergo then reads `checks` as empty and `applies_to` as a filter it can't read, so every subject fails `$applies` with cause `absent`, shown as `<invalid applies_to>`, and gets no other rows. It reads a `from` as giving no subjects (shown as `<invalid from>`), and an `id` as giving the id `null`. A requirement that isn't an object gives no subjects either. A `min_subjects` that isn't a number fails `$min_subjects` too.

### A requirement that only applies sometimes

Expand Down Expand Up @@ -450,7 +455,8 @@ Some things worth knowing:
- a step that can't be a [key](#paths), a number out of range, a badly written [ref](#reading-from-the-input), a ref or `literal` deeper inside a value than ergo reads, or a path that starts with a [name](#naming-subjects) nothing gave
- an `all` or `any` [nested](#nesting) too deep, or a name given twice or badly written
- a check that isn't an object, or one where it can't go, like a custom operator inside `all`
- a field its op doesn't use, like `valeu` or `descripton`. Besides its own parameters, any check can have `description`, `expression`, `substitute` and `inputs`. A [custom operator](#custom-operators) can have any fields.
- a field its op doesn't use, like `valeu` or `descripton`. Besides its own parameters, any check can have `description`, `meta`, `expression`, `substitute` and `inputs`. A [custom operator](#custom-operators) can have any fields.
- a `description` that isn't a string, or a `meta` that isn't an object or holds something JSON can't, as on a [requirement](#requirements). A `null` one counts as not written. This holds for a custom operator too, and for a check inside another one. The expression doesn't show it, but the `$well_formed` row does, as `invalid description`, `invalid meta`, `meta holds a set` or `meta holds a key that isn't a string`. A number in `meta` that's out of range shows as `number out of range`.

The expression says what's wrong: `<unknown op nope>`, `<missing op>`, `<invalid check>` for a check that isn't an object, `<even can't go here>` for a check where it can't go, or `<missing value>` in place of a missing parameter, as in `state == <missing value>`. The [`$well_formed` row](#checks-ergo-adds) lists each check that's written wrong, and what's wrong with it.

Expand Down Expand Up @@ -784,27 +790,34 @@ Three rules:
"checks": {
"$applies": {
"description": "The deployment is in scope",
"expression": "environment == \"prod\""
"expression": "environment == \"prod\"",
"meta": {}
},
"$min_subjects": {
"description": "The in-scope deployment count is at least 1",
"expression": "count(matching(deployments)) >= 1"
"expression": "count(matching(deployments)) >= 1",
"meta": {}
},
"$unique_ids": {
"description": "Every deployment id is unique",
"expression": "count(repeated(ids(deployments))) == 0"
"expression": "count(repeated(ids(deployments))) == 0",
"meta": {}
},
"$well_formed": {
"description": "The requirement is written correctly",
"expression": "fields are known and have the right types and count(checks) >= 1 and require in [\"every\", \"some\"] and steps are keys and numbers fit a float and checks are written right"
"expression": "fields are known and have the right types and count(checks) >= 1 and require in [\"every\", \"some\"] and steps are keys and numbers fit a float and checks are written right",
"meta": {}
},
"approved": {
"description": "Someone approved the deployment",
"expression": "approved_by is a non-empty string",
"meta": {},
"op": "non_empty_string",
"path": ["approved_by"]
}
},
"description": "Every production deployment is approved",
"meta": {},
"require": "every",
"status": "not_met",
"subjects": { "matching": 2, "total": 3 }
Expand All @@ -821,7 +834,9 @@ Three rules:

To tell whether a requirement passed, compare `status` with `"met"` (or `"not_applicable"`, if that counts as passing for you). Don't test for `"not_met"`, so a value you didn't expect counts as a failure.

`subjects.total` counts every subject found at `from`, and `subjects.matching` counts the ones left after `applies_to`. `checks` holds each check as you wrote it, plus the `expression` ergo rendered from it. If you write your own `expression`, yours is used, as long as it's a string. Any other value is ignored, and ergo renders the expression as if it weren't there. A check that uses a [`ref`](#reading-from-the-input) also gets `$refs`: the name and value of each one, as read for this report. The `$` marks it as ergo's, so it can't be mixed up with a field of your own. It also holds the [checks ergo adds](#checks-ergo-adds), each with a `description` and an `expression`.
`description` and `meta` are the requirement's, or `""` and `{}` when it has none, with the numbers in `meta` in their plain form. They're always there, so you can read `meta.control` without first checking that `meta` exists.

`subjects.total` counts every subject found at `from`, and `subjects.matching` counts the ones left after `applies_to`. `checks` holds each check as you wrote it, plus the `expression` ergo rendered from it. Each check has a `description` and a `meta` too, `""` and `{}` when you didn't write them. A filter in `applies_to` can have them as well, and they're checked the same way, but the report doesn't copy them: its filters all share the one `$applies` entry, with ergo's own description. A `description` or `meta` of the wrong type makes the requirement or the check [written wrong](#basic-operators), and the report shows `""` or `{}` in its place, so a tool always finds a string and an object there. If you write your own `expression`, yours is used, as long as it's a string. Any other value is ignored, and ergo renders the expression as if it weren't there. A check that uses a [`ref`](#reading-from-the-input) also gets `$refs`: the name and value of each one, as read for this report. The `$` marks it as ergo's, so it can't be mixed up with a field of your own. It also holds the [checks ergo adds](#checks-ergo-adds), each with a `description`, an `expression` and an empty `meta`.

In an expression, a value written in the policy is shown as JSON, written the same way whatever the policy looked like, so anyone can produce the same text:

Expand Down
68 changes: 63 additions & 5 deletions ergo.rego
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,45 @@ _has_type("subject_type", v) if {
trim_space(v) != ""
}

_has_type("description", v) if is_string(v)

_has_type("description", null)

_has_type("meta", v) if is_object(v)

_has_type("meta", null)

_meta_problems(m) := ({"holds a key that isn't a string" |
walk(m, [_, x])
is_object(x)
some k in object.keys(x)
not is_string(k)
} | {"holds a set" |
walk(m, [_, x])
is_set(x)
}) | {"number out of range" | _out_of_range(m)}

_meta_shaped(v) if {
is_object(v)
count(_meta_problems(v)) == 0
}

_req_meta_problems(req) := {["meta", p] |
is_object(req)
is_object(object.get(req, "meta", null))
some p in _meta_problems(req.meta)
}

_reported_description(x) := object.get(x, "description", "") if {
is_object(x)
is_string(object.get(x, "description", ""))
} else := ""

_reported_meta(x) := json.unmarshal(_json_text(x.meta)) if {
is_object(x)
_meta_shaped(object.get(x, "meta", null))
} else := {}

default _bad_applies_to(_) := false

_bad_applies_to(req) if {
Expand Down Expand Up @@ -83,9 +122,11 @@ _wrong_type_problems := {
"id": "not a list",
"min_subjects": "not a whole number of 0 or more",
"subject_type": "empty or not a string",
"description": "not a string",
"meta": "not an object",
}

_req_problems(req) := union({_type_problems(req), _range_problems(req), _unknown_problems(req), _checks_problems(req), _require_problems(req), _path_problems(req, "from"), _path_problems(req, "id"), _naming_step_problems(req), _where_problems(req)})
_req_problems(req) := union({_req_meta_problems(req), _type_problems(req), _range_problems(req), _unknown_problems(req), _checks_problems(req), _require_problems(req), _path_problems(req, "from"), _path_problems(req, "id"), _naming_step_problems(req), _where_problems(req)})

_type_problems(req) := {[f, _wrong_type_problems[f]] |
some f in _wrong_typed_fields(req)
Expand Down Expand Up @@ -1335,7 +1376,7 @@ _nested_ops := {
"check": [set(), (_leaf_ops | {"all", "any", "any_of"}), (_leaf_ops | {"any_of"})],
}

_field_problems(node) := union({_unknown_fields_problem(node), _missing_fields_problem(node), _range_bounds_problem(node), _range_order_problem(node), _values_problem(node), _value_or_values_problem(node), _patterns_problem(node), _nested_wrapper_problem(node), _cmp_problem(node), _misplaced_fields_problem(node), _each_problem(node), _options_problem(node), _empty_options_problem(node), _empty_option_problem(node), _out_of_range_problem(node), _refs_problem(node)})
_field_problems(node) := union({_wording_problem(node), _unknown_fields_problem(node), _missing_fields_problem(node), _range_bounds_problem(node), _range_order_problem(node), _values_problem(node), _value_or_values_problem(node), _patterns_problem(node), _nested_wrapper_problem(node), _cmp_problem(node), _misplaced_fields_problem(node), _each_problem(node), _options_problem(node), _empty_options_problem(node), _empty_option_problem(node), _out_of_range_problem(node), _refs_problem(node)})

_missing_fields_problem(node) := {concat("", ["missing ", f]) |
some f in object.get(_required_fields, node.op, set())
Expand All @@ -1354,7 +1395,19 @@ _unknown_fields_problem(node) := {concat("", ["unknown field ", _text(f)]) |
node.op in object.keys(_op_fields)
some f in object.keys(node)
not f in _op_fields[node.op]
not f in {"op", "description", "expression", "substitute", "inputs", "as", "each"}
not f in {"op", "description", "meta", "expression", "substitute", "inputs", "as", "each"}
}

_wording_problem(node) := ({"invalid description" |
"description" in object.keys(node)
not _has_type("description", node.description)
} | {"invalid meta" |
"meta" in object.keys(node)
not _has_type("meta", node.meta)
}) | {concat("", ["meta ", p]) |
"meta" in object.keys(node)
is_object(node.meta)
some p in (_meta_problems(node.meta) - {"number out of range"})
}

_op_fields := object.union(_required_fields, {
Expand Down Expand Up @@ -2616,7 +2669,7 @@ _row_inputs(subj, check, item) := array.concat(
_check_inputs(subj, check.substitute, item),
) if check.substitute

_check_def(check, item) := _with_refs(_described(check, item), check)
_check_def(check, item) := _with_refs(object.union(object.remove(_described(check, item), {"description", "meta"}), {"description": _reported_description(check), "meta": _reported_meta(check)}), check)

_with_refs(def, checked) := object.union(def, {"$refs": _ref_inputs(checked)}) if count(_check_refs(checked)) > 0

Expand Down Expand Up @@ -2711,6 +2764,7 @@ default _well_formed(_) := false

_well_formed(req) if {
_typed(req)
_req_meta_problems(req) == set()
_unknown_req_fields(req) == []
count(_checks_of(req)) > 0
_require_of(req) in {"every", "some"}
Expand Down Expand Up @@ -2777,7 +2831,9 @@ _applies_def(req) := {} if {

_applies_to_names(req) := sort(object.keys(_applies_to_of(req))) if is_object(_applies_to_of(req))

_requirement_check_defs(req) := object.union(
_requirement_check_defs(req) := {name: object.union({"meta": {}}, def) | some name, def in _requirement_checks_written_or_added(req)}

_requirement_checks_written_or_added(req) := object.union(
object.union(
{name: _check_def(check, _subject_item_name(req)) | some name, check in _checks_of(req)},
object.union(_min_subjects_def(req), _unique_ids_def(req)),
Expand Down Expand Up @@ -2983,6 +3039,8 @@ report_with_params(doc, params, policy) := r if {
_report_of(doc, policy) := {
"compliant": _policy_compliant(doc, policy),
"requirements": {name: {
"description": _reported_description(req),
"meta": _reported_meta(req),
"require": _require_of(req),
"status": _requirement_status(doc, req),
"subjects": {"total": count(_raw_subjects(doc, req)), "matching": count(_matching_subjects(doc, req))},
Expand Down
Loading
Loading