Authorization policy#
ovstorage-broker and ovstorage-rest compose one listener-auth wrapper above
the host’s storage Stack. The first-party builtin-auth wrapper combines
listener authentication with the in-tree TOML policy engine documented here.
Loaded plugin wrapper kinds whose descriptor declares auth_capable = true
may provide their own authentication and authorization behavior.
There is no standalone authorization-plugin ABI and no authorization-cdylib
loader. Plugin auth wrappers use the ordinary storage Layer ABI and own their
configuration schema and policy lifecycle. A missing or unknown auth kind, a
backend or router kind, or a wrapper without auth_capable = true fails
startup.
Configuration#
Broker listeners and REST servers require an explicit auth choice. This guide covers the built-in form:
[listener.auth]
kind = "builtin-auth"
[listener.auth.config]
jwt_issuer = "https://login.example.com"
jwt_audience = "ovstorage"
jwt_jwks_url = "https://login.example.com/.well-known/jwks.json"
[listener.auth.config.policy]
[[listener.auth.config.policy.policy]]
id = "team-read"
effect = "allow"
principal = "team-*"
operations = ["read", "stat", "list"]
prefix = "s3://corp-prod/team/"
[[listener.auth.config.policy.policy]]
id = "team-upstream-auth"
effect = "allow"
principal = "team-*"
operations = ["update_connection_credentials"]
prefix = "https://assets.example.com/team/"
Both interactive authenticate_connection and proactive
update_connection_credentials requests use the
update_connection_credentials policy operation. Add an allow rule for that
operation and the bound upstream-address prefix when principals must establish
or replace broker-side upstream credentials. A policy that allows only data
operations denies both credential slots by default.
The plugin key accepted inside the policy document is a schema
discriminator. Its only supported value is ovstorage-authz-toml; it does
not select or load a cdylib.
Use auth = "anonymous" only when the listener’s transport is the trust
boundary and unauthenticated access is intentional. Omitting auth
configuration is a startup error.
A plugin form names its loaded auth-capable wrapper and passes its config verbatim to that factory:
[listener.auth]
kind = "corp-auth"
[listener.auth.config]
issuer_alias = "production"
The built-in authn_mode, jwt_*, and peer_dev_current_user settings do not
configure plugin kinds. On a broker TCP listener, host-owned trusted_proxy
and trusted_peers protect forwarded metadata for either auth form. A plugin
may select its captured identity and claim metadata with
forwarded_identity_header (default x-forwarded-user) and
forwarded_claim_headers; those fields are also passed unchanged to its
factory.
Matching#
A rule matches when its principal glob-matches the caller’s principal id,
its operations contains the requested operation (or is "*"), and its
prefix covers the request address.
prefix = "*" matches every request, including operations that carry no
address (list_address_roots, list_backend_kinds, list_connections, …).
A concrete prefix matches only requests that carry one.
A concrete prefix covers an address when the prefix’s decoded path
components are a leading run of the address’s, under the same scheme, host
and port. Component alignment is what stops s3://bucket/foo covering
s3://bucket/foobar. Comparison is on the parsed address — scheme, host,
port, path — and never on its serialized text, so the query and the
fragment are not compared, and a prefix that carries either is a load
error.
When several rules match, the one pinning the most path components wins; ties go to the later declared rule. Writing the same prefix text twice is how a later rule deliberately supersedes an earlier one. Two different spellings that resolve to one scope are refused at load rather than decided by declaration order.
A trailing slash is not part of a node’s identity: s3://bucket/team/ covers
the node s3://bucket/team as well as everything under it. On a flat store
those are two distinct objects, so a rule written for a subtree also covers
the sibling object of that name. This is a widening, and it holds for
allow as much as for deny.
Policy sees the caller’s address, before alias resolution. Authorization
sits above the alias layer, so an alias rule’s from space and its to
space are separate scopes: a grant on azure://account/container does not
cover a caller addressing https://account.blob.core.windows.net/container,
even though the alias layer would resolve one onto the other. This applies
equally to the alias rules the s3, azure and huggingface plugins
generate automatically (see “Generated aliases” in
configuration.md): those
rules are inert behind this policy until you grant their URL space, and
no grant is ever generated for them — that would be automatic widening of a
deny-by-default policy. To let callers use the ordinary cloud URLs of a
connection, grant the URL prefixes alongside the canonical form, exactly as
you would for an alias you authored by hand.
Policy sees the caller’s address before routing, too. A host may route an
address that no configured connection serves to the backend of its scheme and
try it anonymously (see “Anonymous fallback for s3, gs and azure” in
configuration.md).
That changes what is reachable, never what is authorized. The fallback
runs below this layer, so an address reached that way is evaluated here
first, as the caller spelled it, against the same deny-by-default rules: a
bucket nobody configured is denied until you write an allow rule whose prefix
pins its scheme, host and path — s3://public-bucket/ — exactly as for a
configured connection, and a caller the policy denies never reaches the
fallback at all. No grant is ever generated for a fallback-reached address —
that would be automatic widening of a deny-by-default policy. Component
matching then decides who sees the root the fallback publishes. A grant on
s3://public-bucket/ does not cover the scheme root s3:/// — a hostless
scope matches only a hostless address — so the hidden root a granted layer
publishes is invisible to principals holding only bucket-scoped grants:
list_address_roots withholds it, and root_info_for on a bucket the
principal has no rule for answers NoRoute, exactly as for an absent one. A
rule whose prefix is the bare scheme, prefix = "s3://", loads as the scope
s3:///, lists that root, and grants nothing on any bucket. That is the rule
a brokered client’s principal needs: a broker-client layer routes
fallback-only buckets through the broker only when its own layer carries a
local anonymous_fallback grant and its principal holds a rule covering
the bare scheme, because the broker advertises the hidden root only to a
principal who may see it. The bucket rule is still what authorizes the read.
How a prefix must be spelled#
Because matching is on the parsed address, a prefix whose text does not resolve to the scope it reads as is refused at load rather than matched loosely:
prefix_escapes_are_decoded = trueis required — a sibling ofpluginin the policy document, so[listener.auth.config.policy]in the broker’s nested form — before a policy whose prefix carries a percent-escape loads. A prefix’s serialized form carries one whenever the URL parser adds it, so this reaches prefixes written without an escape. The escape decodes:s3://b/100%25names100%. The load error lists each affected rule with the scope it resolves to.Four prefix spellings are refused at load: two spellings of one scope, a
\path separator onfile:/http:/https:/ws:/wss:/ftp:, leading or trailing whitespace and embedded tab/newline/carriage return, and an authority not separated from the path by/. Each would scope something wider than it reads as. The broker operator guide has the worked examples.Userinfo in a prefix does not narrow it. An
allowprefix carrying credentials is refused at load, because dropping them from the comparison widens the rule to every credential.
Built-in runtime contract#
Policy evaluation is deny-by-default and runs on every request. There is no policy-epoch counter, no freshness window, and no persisted policy state.
Authorization runs before routing. A denied address is never routed, so an anonymous fallback attempt or an outbound request cannot be caused by a caller the policy denies.
Policy reload swaps the active policy atomically; the next request observes the new rules.
The Layer gates data operations and filters per-item list and watch results.
The Layer also gates
authenticate_connectionandupdate_connection_credentialsas the policy operationupdate_connection_credentialsagainst the upstream address, when present.The
listpost-filter authorizesstat, notread. A principal grantedstatbut notreadsees entries — names, sizes, mtimes — it cannot read the bytes of. Byte reads are gated by the separatereadcheck.copyandrenameare authorized as their primitive read, write, and delete operations.The winning rule’s
idrides as the decision’s audit explanation.Decisions emit
ovstorage_auth_decisions_totalwith anallow,deny, orerroroutcome.
The built-in typed policy reload operation applies only to builtin-auth.
Plugin auth kinds do not receive an in-place policy mutation: broker SIGHUP
reconstructs the wrapper from its verbatim config as part of the fresh broker,
and the REST gateway requires a process restart for auth configuration changes.
For complete operator configuration, see the
broker operator guide.
Contributors changing the implementation should start with
ovstorage-remote/ovstorage-authz-layer and
ovstorage-remote/ovstorage-authz-policy.