Roles and grants¶
Three blocks of the policy file decide who gets what:
- A role is what may be done: command rules, a privilege level, optional attributes, a session length and an optional per-login proof.
- A subject is who.
- A grant gives a subject some roles in a scope.
The whole vocabulary is in the policy reference. How rules are written, how to test them and how a change is reviewed is on Policy.
Roles¶
roles:
- name: read_only
privilege: readonly
permit: ["show **", "terminal length *"]
max_session_age: 8h
- name: bgp_operator
privilege: operator
permit: ["show bgp **", "clear bgp neighbor **"]
deny: ["clear bgp all"]
per_login_mfa: {accept: [hardware_key_touch]}
privilegenames a level fromprivilege_levels:.readonly,operatorandadminalways exist and keep that order. You may add levels with aparent:: the level a platform renders the new one as where it has no rendering of its own.baseline,reach_denyandrpc_entryare port49's own rule names, so a role may not be called any of them. A policy that does is refused at load; rename the role.- A role's
permitanddenylists follow the rules on Policy. attributes:adds attribute strings to the reply, per platform or underall. A grant may add its own.
max_session_age¶
max_session_age is how long a person's session on a device may last: a duration of at least 1m, or
never. It is set per role, and a session's ceiling is the least of the roles that compose on that device.
- A role that says nothing takes
session.max_session_age_defaultfrom port49d.yaml (12 hours unless you set it). - A
max_session_age:with no value is refused. - If every role says
never, so does the session.
port49 explain prints the result for a subject and device:
| Line | Meaning |
|---|---|
max_session_age 8h0m0s (the least of its roles') |
every role names a duration; this is the least |
max_session_age the lesser of 8h0m0s and session.max_session_age_default |
one role names no value |
max_session_age session.max_session_age_default (no role names a finite one) |
the default decides |
max_session_age never (every role says never) |
no ceiling |
Shortening a role reaches live sessions at the next policy refresh. Lengthening it, or choosing never, reaches
a person's sessions only when they run port49 refresh or log in again. port49 policy diff prints a
session ceiling: line for the change; see Policy.
Subjects¶
A subject is exactly one of three things, and never a mix:
| Form | Who it names | Needs |
|---|---|---|
group: |
a directory group of people, by name or DN | broker.directory configured |
machine_group: |
a group of workloads, by name or DN | a workload certificate verifier whose directory maps the workload to an entry |
identity: |
one or more literal principals: user:<email>, a SPIFFE ID, system:serviceaccount:<namespace>:<name>, or app:<client_id> |
nothing |
subjects:
- name: netops
group: "cn=netops,ou=groups,dc=example,dc=com"
token_ttl: 300s
- name: ci_runners
machine_group: "cn=ci-runners,ou=groups,dc=example,dc=com"
token_ttl: 120s
- name: svc_backup
identity: ["system:serviceaccount:netops:backup"]
username: svc-config-backup
token_ttl: 60s
A subject that has both group: and machine_group:, or either one with identity:, is refused. A subject is
one trust domain, so a person and a workload each get their own:
- a person never matches a
machine_group:subject; - a workload never matches a
group:subject; attached_roles.machinesreaches the machine domain alone.
token_ttl (30 to 900 seconds) is required on any subject a grant names.
machine_group:¶
A machine_group: is the machine-side twin of group:: it names a group in the directory, but its members are
workloads. The broker resolves a workload through the verifier's directory mapping
(broker.identity.machines[].directory) and matches it to the subjects whose group it is in. With no mapping
configured, a machine_group: subject mints for no one, and port49d's start says so.
A machine_group: subject declares no username:, and one that does is refused: one name for many workloads
would put them all in one device login. Its members take the username their own entry gives, or the one an
identity: subject listing the same SPIFFE ID declares. See Workloads.
Usernames¶
The device sees a username, not a principal.
- For a person, the directory supplies it. A
group:subject, and anidentity:subject for auser:principal, declare none whenbroker.directoryis on. - For a machine, a literal identity declares one (
username: svc-config-backupabove): lowercase and printable ASCII, and neverremote, ap49-name or a platform's system user. - A subject whose identities are all SPIFFE IDs may leave it out where the directory mapping supplies one.
An offline run cannot know whether it does, so
port49 policy testreports it asworkload_no_username.
api: rights¶
A principal may always mint for itself, end its own sessions (port49 revoke --mine) and explain its own
decisions. Acting on other people is a right the subject declares:
subjects:
- name: netops_oncall
group: "cn=netops-oncall,ou=groups,dc=example,dc=com"
token_ttl: 300s
api: [revoke_others, explain_others]
| Right | Lets the subject | Needs a key press |
|---|---|---|
revoke_others |
revoke another principal, a certificate, a (device, username) key or an OIDC session; list the revocations |
yes, except the list |
lift_revocations |
lift a revocation by principal or certificate; list the revocations | yes, except the list |
explain_others |
port49 explain --principal another person |
no |
forget_principals |
forget a principal's directory pin | yes |
reset_second_factor |
reset one principal's second factor, or everyone's | yes |
unlock_sign_in |
unlock a locked sign-in | yes |
import_enrollments |
import the enrollment store | yes |
sync, keys_show, replay |
nothing yet: reserved, declared and reported, granting nothing until an endpoint exists | n/a |
An admin-level right also needs a press of the person's security key, bound to that one call. An exposed certificate or agent session alone therefore authorizes nothing admin-level. On a broker with no attestation roots loaded, every admin call is refused; see Hardware keys. A right withdrawn by a policy reload is gone at the next call.
An api: list reads like a grant of authority over other people's sessions, and CI treats it as one:
port49 policy testprints areport: api_rightsline per subject and names the admin-level ones.port49 policy difffails on a subject that gains a right until a session test asserts the subject's whole list (see Writing tests):
tests:
- {subject: netops_oncall, device: leaf1, api: [revoke_others, explain_others]}
A right the subject loses passes. A principal moved to a subject with more rights needs that assertion in a test that names the principal.
Grants¶
grants:
- {subject: netops, scope: dc_fabric, roles: [read_only]}
- {subject: netops_oncall, scope: dc_fabric, roles: [bgp_operator], max_privilege: admin, expires: "2026-12-01"}
A grant names a declared subject and scope and at least one role, each listed once. Two grants with the same subject, scope and roles are refused, so merge them.
expiresis a date (the start of that day, UTC) or an RFC 3339 time, after which the grant stops matching.port49 policy diffandpolicy testread the policy at every later expiry as well as now.max_privilegeis a ceiling on the level a token from the grant may carry, which is how farenablemay take the session. With noenableon the subject, nothing escalates to it. The subject'senableisnone(the default),autoortoken.
A grant on a scope that is on_missing: include is refused (a scope with no match key, or pinned by name
alone, is the exception); see Policy.
When one person matches several subjects, the grants of all of them compose:
| What | How it composes |
|---|---|
| roles | the union |
| level | the highest of the roles |
ceiling (max_privilege) |
the highest across the grants |
max_session_age |
the least |
Reordering grants never changes a decision.
per_login_mfa¶
A policy may ask for a second factor at each login to the devices it marks. This is a proof at the device
login, not the daily sign-in. Write it as per_login_mfa: on defaults:, a role or a scope:
defaults:
per_login_mfa: {accept: [hardware_key_touch, idp_mfa], reuse_for: 15m}
scopes:
- name: core
match: {tier: core}
on_missing: include # the requirement holds on a device the inventory cannot describe
security_log: true
per_login_mfa: {accept: [hardware_key_touch], reuse_for: 5m}
- name: core_access
match: {tier: core}
on_missing: exclude # the grant is earned by data
The scope that carries the requirement is include, so a device whose tier is missing is still held to it.
A grant on an include scope is refused at load, so the grant goes on core_access, a second scope with the same
match (grants: [{subject: netops, scope: core_access, roles: [read_only]}]). For a policy with those two
scopes and a device core1 with tier: core, port49 policy test prints, with nothing about per_login_mfa:
platform_set: 1.0.0
keys: checked against the default key_secret_refs (env: variables starting PORT49_KEY_, file: under /run/port49/keys); pass --config to check port49d.yaml's
report: group_subject: subject "netops" is a group: subject: it mints only with broker.directory configured, through which the broker matches a person to the group; explain and policy tests evaluate it by name
PASS netops on core1: "show version" -> permit permit_dominates
1 tests, 0 failed; 0 shadowed denies, 0 unacknowledged; 0 quarantined, 0 projection refusals
port49 explain on core1 still prints the requirement, per_login_mfa accept hardware_key_touch (defaults ∩ scope core); reuse_for 5m (scope core); applies to humans.
Do not put the requirement on an on_missing: exclude scope to be able to grant on it.
port49 policy test reports that shape as per_login_mfa_on_missing_exclude: a sync that drops tier lifts the
per-login proof from the device.
The keys¶
acceptis a list ofhardware_key_touch(a touch on the token holding the session key),idp_mfa(a fresh identity-provider sign-in showing a second factor) or both. Leaving the block out means no requirement; there is nonone.reuse_foris how long one proof covers later logins, from0m(a proof at every login) to8h, and15mwhen left out.
When several levels set it¶
The levels stack and the strictest wins. On a device the requirement is the intersection of the accept
lists of every level that applies, with the least reuse_for. A lower level never weakens a higher one, and
an empty intersection is a load error naming both levels.
A scope that selects by an attribute other than name must declare on_missing and security_log: true,
since whoever edits the attribute can lift the requirement. port49 policy test also reports
per_login_mfa_on_missing_exclude where an exclude scope could lose it. A scope pinned by name needs
neither.
Machines¶
A machine principal is exempt except under a level that sets applies_to_machines: true. A workload proves
with hardware_key_touch alone, from a key and touch slot on one token.
What a login sees¶
- A mint under an unmet requirement is answered
denywith the reasonper_login_mfa_requiredand a challenge. - A requirement that no method this broker can verify satisfies is
per_login_mfa_unavailablefor that device. - A requirement gates logins and refreshes, never a live session: adding one revokes nothing.
Reviewing a change¶
port49 policy diff fails, as per_login_mfa_weakened, on a weakening that no session test asserts. The test
must be new or changed against the old file; one carried over unchanged does not count. Widening the scope
above to accept: [hardware_key_touch, idp_mfa] prints this and exits 1:
per_login_mfa_weakened: netops on core1: accept [hardware_key_touch] reuse_for 5m (defaults ∩ scope core) -> accept [hardware_key_touch, idp_mfa] reuse_for 5m (defaults ∩ scope core); no session test asserts it: add {subject: netops, device: core1, per_login_mfa: {accept: [hardware_key_touch, idp_mfa], reuse_for: 5m}}
The line ends with the session test to add; Writing tests covers the keys.
Reading why a grant applied¶
Offline, port49 explain shows the subject, the grants that matched, the roles (and which scope attached
each), the level, the ceiling, the session age and the per_login_mfa requirement for one device, with the
reason a command was permitted or denied:
port49 explain --policy policy.yaml --inventory inventory.yaml --subject netops --device leaf1
Signed in, port49 identity prints whom the broker resolves you to (your subject, grants, api: rights) and
port49 explain --device NAME asks the broker the same question from the policy in force. The per_login_mfa
line reads, for example, per_login_mfa accept hardware_key_touch (defaults ∩ scope core); reuse_for 5m (scope core); applies to humans, or per_login_mfa none: the default is never invisible.
For a review, port49 access review --policy FILE --inventory FILE --subject NAME prints one subject's grant
table, with the roles, ceiling, expiry and device count per row.
To offboard a machine subject or a literal identity, use port49 grants remove --subject NAME:
- by itself, it prints the policy without the subject's grants and its block;
--writereplaces the file;--propens the pull request that carries the removal.
The result must load against the inventory before anything is written. People in a directory group are offboarded in the directory.