Skip to content

feat: policy-forced supplementary groups with container_config.group_add (#93) - #94

Merged
abienkowski merged 5 commits into
mainfrom
feat/container-config-group-add
Oct 15, 2026
Merged

abienkowski merged 5 commits into
mainfrom
feat/container-config-group-add

Conversation

@abienkowski

@abienkowski abienkowski commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

Description

Closes #93.

Since #88 (v0.4.0), a non-empty HostConfig.GroupAdd is always denied, so no policy can give a service a supplementary group. The services that read a host-mounted file through a group (the shared JWT secret, root:jwt 0640; the validator's keystores) run as a fixed non-root user: that sets only the primary UID and GID. Their deploy roles pass the host GID with community.docker.docker_container's groups:, which becomes HostConfig.GroupAdd, and with v0.5.0 every one of those creates gets a 403.

What changes

  • New optional policy key container_config.group_add: [string]. It works like user. At load time, each entry must be a decimal GID that fits in 32 bits or a group name ([a-z_][a-z0-9_-]*$?, at most 32 characters). These fail startup with an error that names the file: 0 in any decimal form (00) and root (the root group), duplicates (numeric ones by value, so 064190 after 64190), and anything else. An empty list is the same as an unset key.
  • ContainerConfigMutator overwrites HostConfig.GroupAdd with the list, as user overwrites User. A caller's own list is replaced, not denied, and a caller that sends the same list sees no change, so docker_container stays idempotent. A non-object HostConfig is still left for HostConfigGate to deny.
  • HostConfigGate allows a non-zero GroupAdd only when it equals the policy's list exactly, in order. Policies without group_add keep the Container create: default-deny HostConfig, validate Mounts and the bind read-only mode #88 rule (absent or zero). The exact-match check is defence in depth: if the mutator ever stops forcing the list, the gate does not open up. Case variants (groupadd, groupAdd) are still denied.
  • Entries must be YAML strings ('64190', not 64190). The three YAML libraries disagree on unquoted scalars: Go and Rust read 0x10 as "0x10" and TypeScript as 16. Taking only strings means all three see the same text.
  • Spec (create_model in spec/docker_socket_policy.qnt): ContainerConfig.groupAdd (one atom for the whole list), the mutator and gate rules, groupadd as a case variant, and a new model policy GROUPS. The new security property daemonGroupsOnlyForced says the daemon reads GroupAdd, under any spelling, only as the selected policy's list or empty. daemonSeesOnlyAllowlistedHostConfig also checks that the forced list is kept. There are six new rows. The state machine's beacon forces a list, so the P0 invariant hostConfigInAllowlist checks the forced value on every step.
  • Tests:
    • The same rows by name in Go, Rust and TypeScript:
      • 8 HostConfig rows on a groups policy.
      • 9 rows that run HostConfigGate alone: another list, a reordered list, a subset, a superset, a number entry or a bare string is denied.
      • 14 load-time rows and an empty-is-unset test.
    • deploy/test.sh has 8 new checks against the real daemon (see Verification).
  • Docs:
    • README: the policy example, a paragraph on group_add, a GroupAdd row in the HostConfig rules table, the mutator row and the counts.
    • spec/README.md, AGENTS.md counts, and a group_add example in config/beacon.yaml.

Review notes

  • Release: merging cuts v0.6.0 (minor). Simulated squash on v0.5.0: bump=minor tag=v0.6.0.
  • No pre-93 instance in the spec. Before Policy-forced supplementary groups: container_config.group_add #93, GroupAdd had to be zero for every policy. That is the rule create still runs for BEACON and TOOLS, and it is strictly more restrictive. daemonGroupsOnlyForced held then, so there is no gap to witness. The spec README says this and gives the mutations that do break the property.
  • Integration policy: deploy/config/dsp-it-a.yaml gains group_add: ['64190', '64191']. These are numeric GIDs, so the existing Lifecycle actions and DELETE are allowed on containers that no policy covers #86 start of dsp-it-a still works without a matching /etc/group entry. dsp-it-b has no group_add and covers the deny case. No new image prefixes or container names.
  • Error-message change in Rust: Rust now prefixes YAML parse errors with policy file <name>:, as its other load errors do.

Verification

  • Spec mutation: if the gate accepts any GroupAdd, createSoundTest fails, along with groupAddDenied, groupAddPolicyListWithoutGroupAddDenied and groupAddGateExactOnly. If the mutator keeps the caller's GroupAdd, createSoundTest and groupAddCallerReplaced fail.

  • Before the fix (the test commit, plus only the new group_add type field so that the tests compile):

    • Go: 5 HostConfig rows, groupAddGateForcedListAllowed, 12 load-time rows and the empty-is-unset test fail:
      hostconfig_test.go:85: forwarded HostConfig.GroupAdd = <nil>, want [64190 jwt]
      hostconfig_test.go:61: expected allow, got: validation failed: HostConfig.GroupAdd is not allowed for service svc
      hostconfig_test.go:264: expected allow, got: HostConfig.GroupAdd is not allowed for service svc
      manager_test.go:391: NewManager loaded, want error "policy file a.yaml: group_add entry \"0\" is the root group, which is not allowed"
      manager_test.go:391: NewManager loaded, want error "group_add entries must be strings"
      
    • Rust: the same rows fail: test result: FAILED. 156 passed; 4 failed (5 rows failed, plus groupAddGateForcedListAllowed, 12 load-time rows and test_manager_empty_group_add_is_unset).
    • TypeScript: # tests 330 / # pass 321 / # fail 8.
  • This branch:

    • make test-all: Go 133, Rust 160, TS 330 (1 skipped).
    • make lint-all and make build-all pass.
    • make typecheck passes.
    • make test-spec passes all 20 runs (create: 50 passing).
    • make verify (BACKEND=typescript): [ok] No violation found for both specs.
    • make test-release: 9 passed.
  • Integration: green on CI (GitHub runner, real daemon): ALL 116 TESTS PASSED for Go, Rust and TypeScript. The local OrbStack daemon hung after the machine slept, so there is no local run and no pre-fix integration capture. The 8 new checks:

    • Creates of dsp-it-a without GroupAdd, with the forced list, and with another list all return 201, and inspect shows "GroupAdd":["64190","64191"].
    • groupadd is denied.
    • dsp-it-b with GroupAdd is denied.

    They bring the suite to 116 per implementation (108 + 8). The socket suite (make test-integration-sock, not in CI) is still to run locally.

Type of change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update

Implementation(s) changed

  • Go
  • Rust
  • TypeScript
  • Quint specification
  • CI / infrastructure

Testing

  • Unit tests pass (make test-all)
  • Integration tests pass (CI: 116 per implementation; socket suite not yet run)
  • Quint verification passes (make verify, make test-spec)
  • New tests added for the change

Checklist

  • I have read CONTRIBUTING.md
  • My code follows the project's coding style
  • I have updated documentation as needed

🤖 Generated with Claude Code

abienkowski and others added 4 commits October 14, 2026 16:53
container_config.group_add forces HostConfig.GroupAdd, as user forces
User. In create_model:

- ContainerConfig gains groupAdd, an atom for the whole forced list ("" when
  unset). The mutator replaces a GroupAdd spelled exactly so with the
  policy's list; HostConfigGate allows a non-zero GroupAdd only when it
  equals that list, so it does not open up if the mutator ever stops
  forcing it.
- groupadd joins the case variants the daemon reads as GroupAdd.
- Policy GROUPS (BEACON with a forced list) joins MODEL_POLICIES, and the
  list's atom joins VALUES, so a caller that sends the same list is in REQS.
- Security property daemonGroupsOnlyForced: the daemon reads GroupAdd,
  under any spelling, only as the selected policy's list or empty.
  daemonSeesOnlyAllowlistedHostConfig also checks the forced list is kept.
- Rows: groupAddForced, groupAddCallerReplaced, groupAddSameListUnchanged,
  groupAddPolicyListWithoutGroupAddDenied, groupAddCaseVariantDenied and
  groupAddGateExactOnly (the gate alone).
- The state machine's beacon policy forces a list, so the P0 invariant
  hostConfigInAllowlist checks the forced value.

No pre-93 instance: before #93 GroupAdd had to be zero, which is the rule
`create` already runs for BEACON, so the old behaviour has no gap to
witness.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The same rows, by name, in Go, Rust and TypeScript:

- HostConfig rows on a new "groups" policy (std plus group_add
  ["64190", "jwt"]): groupAddForced, groupAddForcedNullHostConfig,
  groupAddCallerReplaced, groupAddCallerNullReplaced,
  groupAddSameListUnchanged, groupAddCaseVariantDenied,
  groupAddNonObjectHostConfigDenied; and
  groupAddPolicyListWithoutGroupAddDenied on "std" next to the existing
  groupAddDenied.
- groupAddGate* rows run HostConfigGate alone on "groups", as if the
  mutator stopped forcing the list: only the exact list or a zero value
  passes; another list, a reordered one, a subset, a superset, a number
  entry or a bare string are denied.
- Load-time rows: GIDs and names load (up to 32 characters, 4294967295);
  an empty list loads and is unset; 0, 00 and root are refused as the root
  group; duplicates are refused, also 064190 after 64190; GIDs above
  uint32, upper case, spaces, a leading dash, 33 characters, "" and an
  unquoted YAML number are refused.

deploy/config/dsp-it-a.yaml forces group_add ['64190', '64191'], and
deploy/test.sh gains 10 checks against the real daemon: creates of dsp-it-a
without GroupAdd, with the forced list and with another list all give 201
and the daemon reports the forced list on inspect; a lower-case groupadd is
denied; dsp-it-b, which has no group_add, still denies GroupAdd.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…add (#93)

Since #88 a non-empty HostConfig.GroupAdd is always denied, so no policy
could give a service the host group it needs to read a shared file (the
JWT secret, root:jwt 0640, or a keys directory). In all three:

- New optional policy key container_config.group_add: a list of strings.
  At load time each entry must be a decimal GID that fits in 32 bits or a
  group name ([a-z_][a-z0-9_-]*$?, at most 32 characters); 0 in any decimal
  form and root are refused as the root group; duplicates are refused,
  numeric ones by value (64190 and 064190). Entries must be YAML strings:
  Go and Rust would read an unquoted 0x10 as "0x10" and TypeScript as 16.
  An empty list is the same as an unset key.
- ContainerConfigMutator overwrites HostConfig.GroupAdd with the list, as
  user overwrites User. A caller's list is replaced, not denied, so
  community.docker.docker_container sending the same list stays idempotent.
  A non-object HostConfig is still left for HostConfigGate to deny.
- HostConfigGate allows a non-zero GroupAdd only when it equals the
  policy's list exactly, in order. Without group_add the rule is unchanged
  (absent or zero), and the gate does not open up if the mutator ever stops
  forcing the list. Case variants such as groupadd are still denied.
- Rust prefixes policy parse errors with the file name, as the other
  load errors do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
README: group_add in the policy example and a paragraph on what it does
and what fails startup; a GroupAdd row in the HostConfig rules table; the
mutator row; test counts. spec/README.md: the GROUPS policy, the
daemonGroupsOnlyForced property, the #93 rows, and why there is no pre-93
instance. AGENTS.md: counts. config/beacon.yaml: a group_add example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@abienkowski abienkowski added the Type: Feature Added to issues and PRs to identify that the change is a new feature. label Oct 15, 2026
The branch adds 8 integration checks, not 10: 108 + 8 = 116, as the CI
run on PR #94 reports for Go, Rust and TypeScript.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@abienkowski
abienkowski merged commit 427070d into main Oct 15, 2026
7 checks passed
@abienkowski
abienkowski deleted the feat/container-config-group-add branch October 15, 2026 02:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Type: Feature Added to issues and PRs to identify that the change is a new feature.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Policy-forced supplementary groups: container_config.group_add

1 participant