An OPA/ABAC-enforcing, S3-compatible authorization gateway.
s0 terminates the S3 protocol, makes a per-request OPA decision on the parsed request, and re-issues allowed requests to a backend under a per-tenant credential. Enforcement lives in the gateway's own policy — never in a backend-native feature — so one access model holds uniformly over Ceph RGW and any remote S3, with one per-end-user audit trail.
It is built in Rust on s3s, which handles the S3 wire
protocol and SigV4.
It is not a byte proxy with an authorization hook bolted on. s3s deserializes each
request into a typed value; OPA decides on that value; s3s-aws re-issues the request
to the backend from the same value. Every operation s0 supports is one it owns.
Operations it does not own are denied, not passed through.
s0 knows all 99 operations in the s3s 0.14.1 surface. 23 are enforced and
forwarded; the other 76 are refused with 403 AccessDenied. There is no third
category — an operation with no entry in the table cannot reach a backend.
The table is src/access/optable.rs and it is the only source
of truth. tests/gate_blackbox.rs drives every refused operation as a signed HTTP
request through the real gate, and tests/readme_optable.rs fails if the counts below
drift from the table.
Each is authorized against one of six grant verbs. A grant is per-subject, per-bucket, per-verb, with object-prefix scopes.
| Grant verb | Operations |
|---|---|
read |
HeadBucket, GetBucketLocation, ListBuckets |
read_objects |
GetObject, HeadObject, GetObjectAttributes, GetObjectTagging, ListParts |
list_objects |
ListObjects, ListObjectsV2, ListMultipartUploads |
write_objects |
PutObject, PostObject, CopyObject, CreateMultipartUpload, UploadPart, UploadPartCopy, CompleteMultipartUpload, AbortMultipartUpload |
delete_objects |
DeleteObject, DeleteObjects |
write_object_tags |
PutObjectTagging, DeleteObjectTagging |
That covers ordinary object storage: get/put/head/delete, multipart upload, server-side copy, listing, and object tagging.
Bucket lifecycle and configuration — 63 operations. Every *BucketAcl, *BucketPolicy,
*BucketCors, *BucketEncryption, *BucketVersioning, *BucketLifecycle,
*BucketReplication, *BucketNotification, *BucketTagging, *PublicAccessBlock,
*BucketWebsite, *BucketLogging, inventory/metrics/analytics/tiering configuration,
plus CreateBucket and DeleteBucket.
This is a deliberate scope boundary, not a gap: s0 is a data-plane gateway. A bucket
created or destroyed through S3 has no record in whatever control plane owns the bucket,
and a bucket policy or CORS document written through S3 changes who can reach the data
with nothing upstream knowing. Those five verbs (create_bucket, delete_bucket,
read_bucket_config, write_bucket_config, write_object_acl) are held in
NON_GATEWAY_VERBS as a classification only — three tests hold that set disjoint from the
grantable vocabulary, so flipping one of these operations to enforced does not quietly
compile.
Object-level operations outside the model — 11 operations.
| Operation(s) | Why |
|---|---|
PutObjectAcl, GetObjectAcl |
Per-object ACLs are a second authorization system s0 would have to keep consistent with the bundle. Conferring an ACL is refused in code on every write path. |
PutObjectRetention, GetObjectRetention, PutObjectLegalHold, GetObjectLegalHold, PutObjectLockConfiguration, GetObjectLockConfiguration |
Object Lock / WORM. The grant vocabulary has no verb that could authorize defeating retention, so x-amz-bypass-governance-retention is refused unconditionally. |
RestoreObject |
Glacier-class restore; no verb, no backend parity. |
SelectObjectContent |
Server-side SQL over object contents — the response is a projection s0 cannot authorize at object granularity. |
GetObjectTorrent |
Legacy. |
ListObjectVersions |
Versioned listing is not modelled; the prefix-scoping rules below apply to the unversioned listing only. |
ListDirectoryBuckets |
S3 Express One Zone directory buckets are out of scope. |
Structurally unauthorizable — 2 operations. These are refused ahead of the coverage table, so no pushed policy can enable them:
CreateSessionreturns backend session credentials the client would then use directly against the backend — a permanent, total bypass of the gateway.WriteGetObjectResponsecarries neither a bucket nor a key. There is nothing to authorize.
- Versioning:
version_idrides into the OPA input on the operations that accept it, but there is no version-aware grant shape and no versioned listing. - SSE-C / KMS: encryption headers pass through to the backend; s0 does not authorize on them.
- Presigned URLs are supported (the STS session token is resolved from the query string), and the presigned credential is redacted from the audit record.
- Anonymous access is never allowed. There is no public-bucket concept.
- Object-level, per-prefix, per-operation authorization — not bucket-granular
(
policy/gateway/authz.rego). - Blind spots an in-backend hook cannot see, all authorized on the parsed request:
CopyObject/UploadPartCopysource — authorizes the source read and the destination write;DeleteObjectskeys — authorizes each key, and strips denied keys from the forward rather than failing or allowing the batch;PostObjectform-upload key.
- Request riders: canned ACLs and
x-amz-grant-*headers, inlinex-amz-tagging, andx-amz-bypass-governance-retentionare read and enforced rather than riding through unexamined. The answer is a denial, never a silent strip — a strip answers200to a request whose intent was not honoured. - List scoping, AWS-shaped: an unbounded
ListObjectsV2— no prefix at all — is denied for a prefix-scoped principal, exactly ass3:ListBucketwith ans3:prefixcondition denies it in AWS. A prefix wider than the grant but overlapping it is rewritten down to the grant, or fanned out across several. s0 does not narrow a listing the caller did not scope: a silently filtered listing reads as the whole bucket. - Live policy and live revocation: OPA holds the policy; a revoked grant denies on the next request. No policy is ever baked into a credential.
freeze_writesorg kill-switch, per-bucket denylist, and the grant superset.
Three independent layers, and they only work together:
| Layer | What it stops |
|---|---|
check, pre-deserialization |
Denies anonymous requests and any operation the table does not mark Enforced. Emits a gate audit record naming the stage, operation and access-key id. |
AuthzProof (src/access/proof.rs) |
The enforce path mints a proof; the forward path requires one bound to the same operation. A typed hook that forgets to authorize cannot reach a backend — it fails with an internal error. |
| PDP error handling | Any PDP error, missing key, or unparseable decision denies. |
The table proves a hook exists; the proof proves it ran.
flowchart LR
C[S3 client] -->|SigV4| A[S3Auth: get_secret_key]
A --> K[check: deny-by-default backstop<br/>+ resolve end-user identity]
K --> H[typed per-op hook<br/>builds OPA input on the parsed request]
H --> P[PDP: regorus / sidecar OPA<br/>+ revision-keyed cache]
P -->|allow + obligations| X[re-sign with the per-tenant<br/>backend credential]
P -->|deny| D[403 AccessDenied]
X --> B[(Ceph RGW / remote S3)]
H -.one record per request.-> AU[audit sink<br/>OPA decision-log shape]
The s3s pipeline, verified against 0.14.1 (see
docs/substrate-api.md): SigV4 verify → region →
S3Access::check (no body) → body buffer → deserialize → typed S3Access hook with the
full parsed input → S3::<op> → backend.
The control plane is the source of policy. It pushes per-tenant grant data — optionally with the rego module itself — to the gateway as a bundle. s0 is policy-content-agnostic: it enforces whatever bundle is pushed, and a revocation lands on the next request.
A bundle carries { "policy": "<rego>", "data": { … } }, or just the data — in which case
s0 falls back to the compiled-in default policy
(policy/gateway/authz.rego). That default is also the
local-development policy and the oracle the parity gate replays against.
Two engines sit behind one Pdp trait:
- Sidecar OPA (
mode: "sidecar") — the shipping default. OPA is fed the bundle out-of-band; s0 calls it over loopback. - Embedded regorus (
mode: "embedded") — the in-process fast path, permitted in production only behind a dual-engine parity gate: the two engines must agree byte-for-byte over the golden corpus. See ADR-005.
Four listeners on four ports, with three different authentication postures. Which
posture applies is a property of the socket, never of the path — a port where "is this
authenticated?" is answered by a route match is how a credential mint ends up answering
anonymously. GatewayConfig::validate refuses a config in which any two share a port.
| Port | What | Authentication | Exposure |
|---|---|---|---|
listen (8014) |
the S3 data plane | SigV4, per-request authorization | ingress-fronted; public |
admin_listen (8016) |
/healthz /readyz /metrics |
none, by construction — a kubelet probe cannot present a secret | probes and scraping only; never the ingress |
sts_mint.listen (8015) |
AssumeRoleWithWebIdentity (AWS STS wire protocol) + a legacy bearer-OIDC exchange |
none, by design — a valid web identity token is the credential, exactly as at sts.amazonaws.com |
optional; absent unless configured. Own hostname when present. |
internal.listen (8017) |
POST /internal/v1/sts/sessions |
X-Shared-Secret, constant-time |
control plane only; never the ingress, never the data plane |
Two of these mint credentials, and they are deliberately not the same socket. 8017 accepts
assertions about a caller the control plane has already authenticated, and is
shared-secret authenticated. 8015 accepts no assertions at all — it derives the principal
from the token, the tenant from the RoleArn, and the organization from s0's own routing
table. Collapsing them would put the shared-secret boundary behind a routing decision.
The internal listener is absent unless internal is present in the config. A missing or
empty internal.shared_secret refuses every request — there is no unauthenticated
mode — and says so at error level once at startup. Fence it with a NetworkPolicy; the
secret is the second line, not the first.
Every S3 SDK can obtain one with stock configuration — the same shape an EKS pod uses for IRSA, because it is the same call:
AWS_ROLE_ARN=arn:aws:iam::<tenant>:role/<tenant>-sts-role
AWS_WEB_IDENTITY_TOKEN_FILE=/var/run/secrets/…/token
AWS_ENDPOINT_URL_STS=https://<org>.s3-gw-sts.<domain>
AWS_ENDPOINT_URL_S3=https://<org>.s3-gw.<domain>
The SDK mints, reads Expiration off the XML response, and re-mints before it lapses.
The RoleArn carries the tenant, because an OIDC token generally cannot: the tenant is
a property of the resource being addressed, not of the user, so a user with three tenants
would need three values of a claim minted once at login. So the token proves who you are,
the RoleArn names which tenant you want to act in, and the organization is resolved
from s0's own tenant→org binding and never from a claim. The role name confers nothing —
s0 has no role objects; authority comes from the bundle at decision time.
STS is on its own hostname, not a path on the S3 host. Ceph RGW and MinIO fold the two
together; AWS does not, and neither does s0 — a path prefix would collide with a bucket
named sts, and folding an unauthenticated surface into the SigV4 data plane makes "is
this request authenticated?" a matter of parsing a POST body correctly on the public port.
Details of token acceptance — the configured audience list, and the additive bundle route
for tenant service accounts — are in src/webidentity.rs.
s0 verifies inbound SigV4 itself. For STS sessions the secret is derived,
HMAC(master_keys[kid], kid ‖ 0x00 ‖ sid), from the access-key id — no per-session secret
is stored and no hot-path lookup happens. The session claims (sub, groups, tenant, org,
expiry) ride in a signed token bound to the same sid. Revocation stays live because
policy lives in OPA, not in the token (src/auth/sts.rs).
Access-key ids are HFST<kid>.<sid>. The kid names the master key that derived the
secret, which is what makes master-key rotation online — replacing a single key would
otherwise 403 every live session at once, indistinguishable from a fleet-wide auth outage:
The signing key is not yet a ring: the token names its master key but not its signing key, so replacing it invalidates live sessions. Rotate it in a window.
| Module | Role |
|---|---|
authz |
the OPA input contract and the decision type — the core seam |
pdp |
Pdp trait; embedded regorus + sidecar OPA; revision-keyed decision cache |
auth |
identity authority, own STS, long-lived derived keys |
access |
the OPA gate: deny-by-default check + typed per-op hooks |
proxy |
per-(backend, tenant) client pool and dispatch |
audit |
one reasoned decision record per request; async, non-blocking, disk-spill |
gateway / server |
assembly and hardened hyper serving |
admin / shutdown |
/healthz /readyz /metrics; one signal, ordered drain |
internal |
the authenticated control-plane surface |
mint / webidentity |
the credential doors |
GATEWAY_CONFIG=/etc/s0/gateway.json cargo run --releaseSee docs/gateway.example.json and
docs/bundle.example.json.
s0 is a Deployment, not a singleton. Three consequences a single-replica deployment never exercises:
- The audit spill is per-pod. It is read-whole / POST / delete-whole, correct only for
a single writer. Give each pod its own path —
"spill_path": "/var/lib/s0/audit-spill-${POD_NAME}.ndjson", interpolated at config load — on node-local scratch (emptyDir). A shared RWX volume is unsupported. If two pods do land on one path, the second detects the.ownermarker, relocates, and logs an error rather than deleting records it never read. - Readiness means "has polled the control plane", not "holds a bundle revision" — a
revision is seeded from the local file at boot, so the weaker check would admit a
stale-policy replica.
/readyzgates on ≥1 successful poll, and fails from the moment SIGTERM arrives so the pod leaves the Service before it stops accepting. - The grace period must exceed ~45s. SIGTERM → readiness fails → S3 front drains (≤30s)
→ internal API and mint drain → audit worker drains (≤10s) → admin listener stops last.
Kubernetes' 30s default truncates the audit drain and loses records; set
terminationGracePeriodSeconds: 60.
livenessProbe: { httpGet: { path: /healthz, port: 8016 } } # never depends on the control plane
readinessProbe: { httpGet: { path: /readyz, port: 8016 } }The image is distroless, so an exec probe is impossible.
s0_audit_dropped_total counts records this process is known to have lost. Any
non-zero value is an audit-integrity incident; alert on increase. It includes
s0_audit_spill_abandoned_total — records still in the local spill when the worker exits.
The spill is node-local and dies with the pod, so those are lost, not pending.
s0_audit_gate_suppressed_total is deliberately not part of that total. Gate denials
(unsigned requests, unenforced operations, rejected credentials) are the one audit stream
an unauthenticated caller controls the rate of, so they are budgeted — without a bound a
scanner could evict the decision records of real access. A sustained non-zero value means
someone is scanning.
Needs OPA 1.13.1 on PATH. The dual-engine parity
gate is the only check that licenses the embedded regorus engine, so it fails when its
oracle is missing rather than skipping. S0_ALLOW_NO_OPA=1 downgrades it loudly if you
genuinely cannot install one.
cargo build
cargo test # unit + typed-hook e2e + golden corpus + parity
# + every refused operation driven over real HTTP
bash tests/e2e/run.sh # real stack: aws-cli -> gateway -> MinIO
PDP_MODE=sidecar bash tests/e2e/run.sh # …the same scenarios against a real OPA sidecar
S0_CAPTURE_REGENERATE=1 cargo test --test golden_capture # re-record the captured corpus| Property | How |
|---|---|
| the policy is tested against the emitted input, never a hand-written one | every fixture in tests/data/captured_inputs/ is recorded at GatewayAccess::decide, the single funnel every PDP question passes through |
| a renamed or dropped input field fails the build | OpaInput carries deny_unknown_fields; every capture must round-trip, and adding a field is a compile error until OPA_INPUT_FIELDS is updated (tests/fixture_drift.rs) |
| a policy cannot read a field the gateway never sends | every input.<path> in the shipped rego must resolve in a captured input — the exact failure that once left 35 rego tests green over a deny-all production |
| every refused operation is refused over real HTTP | all 76 are driven as signed requests through the real check, table-driven off s3s's own route table (tests/gate_blackbox.rs) |
| the embedded engine cannot be licensed by an absent oracle | the parity gate fails rather than skips when opa is missing, and the oracle version is pinned to production's in CI, docker-compose and the test itself (tests/parity.rs) |
| cursors and cache keys survive a rebuild | content_revision / scope_hash are SHA-256, pinned by tests/golden_hash.rs — DefaultHasher is not stable across toolchains |
| a configured secret cannot be printed | every plaintext credential is a Secret<String> whose Debug renders Secret(<redacted>), with no Display and no Serialize (src/secret.rs) |
| this README's operation counts match the code | tests/readme_optable.rs |
s0 and whatever control plane pushes its bundles must agree on the rego entrypoint
(data.s3.authz.decision). Disagreement fails silently: a package the entrypoint does
not address evaluates to undefined, and everything is denied.
tests/external_bundle.rs pins it by replaying a real
serialized bundle produced by a third-party control plane
(tests/data/platform/) through s0's production parse_bundle →
reload → decide path, asserting that a granted read comes back allow. A matching
package name that still evaluates to undefined is exactly the deny-all bug; only an
allow rules it out.
s3s is experimental, effectively single-maintainer, and on the critical path. It is
pinned to the exact version (s3s = "=0.14.1") so upgrades are deliberate events.
s3s-aws is pinned just as hard and for a stronger reason: the gate's safety argument —
"an operation with no hook falls through to NotImplemented" — is a claim about which
methods s3s-aws's generated Proxy overrides. A patch bump that overrode one more would
open a forwarding path with no test failing. Guarded by tests/op_coverage.rs.
A conformance suite against a live backend is scaffolded in
.github/workflows/conformance.yml. It is not yet
wired — the harness step exits 1 by design — so it does not currently gate anything.
To make an upgrade fully hermetic, vendor the tree and review the diff on each bump:
cargo vendor vendor/
printf '[source.crates-io]\nreplace-with = "vendored"\n[source.vendored]\ndirectory = "vendor"\n' >> .cargo/config.tomls0 ships as a container image from its own repository on its own version line; a consumer
pins a tag and never builds s0. CI is the only thing that publishes the image —
scripts/release-image.sh refuses to push anywhere but GitHub
Actions. The full procedure and the reasoning behind it are in
docs/releasing.md.
Architecture decisions: docs/adr/. Substrate API reference (s3s, s3s-aws,
regorus): docs/substrate-api.md.
See CONTRIBUTING.md. Security reports: SECURITY.md.
Business Source License 1.1 (BUSL-1.1), converting to Apache-2.0 on the change
date (2030-08-18, four years after first publication). See LICENSE;
the future Apache text is in LICENSE-APACHE-2.0.txt.
Until that date, in short:
- Free — evaluation, development, testing, CI, benchmarking, security research and demonstration, for everyone, companies included; plus production use by individuals, non-profits, educational institutions and public research bodies.
- Paid — any production use by or for a commercial entity, including a purely
internal deployment securing your own object storage. That needs a commercial
license, granted against payment of a fee and obtained before the use starts:
see
COMMERCIAL.md, or write to contact@nudibranches.tech for terms and pricing.
That summary is a convenience; LICENSE is what governs.