Core Concepts — Object Storage
The Object Storage domain is defined by the proto package dodil.data.storage.v1. From the proto itself:
Owns: buckets, bucket policy (S3-style ACL), CORS, and the S3-like admin surface for objects (list, info, delete, presigned URL). Does NOT own: sources or their credentials (see
dodil.data.source.v1), rules / sync / ingest jobs (seedodil.data.ingest.v1), or the pillar engines (vector / warehouse). Storage stays thin: the data plane, not the intelligence plane.
Each entity below has a gRPC view (the proto message) and an HTTP view (the JSON your client sees on the wire). Use the toggle at the top of each section to flip — your choice sticks across the page and across visits.
What to expect in the HTTP view:
- Field names are camelCase (e.g.
storageUsedBytes). - Enums are wire-name strings (e.g.
"BUCKET_STATUS_ACTIVE"). int64values are JSON strings — JavaScript can’t represent them as numbers without precision loss.- Every field is always present — defaults are not omitted.
- Timestamps are Unix milliseconds.
Bucket
The central entity. Most K3 APIs are bucket-scoped — sources, rules, vector engines, and table engines all attach here.
HTTP
{
"name": "demo",
"description": "Workflow bucket",
"status": "BUCKET_STATUS_ACTIVE",
"internalSourceId": "src_01HEFGHIJK",
"storageUsedBytes": "1048576",
"storageQuotaBytes": "0",
"objectCount": "42",
"sourceCount": "3",
"ruleCount": "2",
"indexCount": "1",
"createdAt": "1700000000000",
"updatedAt": "1700001000000",
"accessMode": "BUCKET_ACCESS_MODE_PRIVATE",
"drn": "drn:dodil:k3:uk-lon-1:org_01HABC:bucket/demo"
}Notes:
internal_source_idreferences an auto-createdINTERNAL_S3source managed by the Source service — a direct S3PUTtriggers the same ingest rules as an external sync.- The
*_countandstorage_used_bytesfields are server-maintained aggregates. CreateBucketacceptsaccess_mode, but the CLI’sdodil data bucket createdoes not expose it — a new bucket isPRIVATEuntil youdodil data bucket update <name> --access-mode public.
Access modes
access_mode on a bucket governs who can read or write its objects. The bucket owner always wins in all three modes; a denial is a flat 403 AccessDenied in every case, deliberately indistinguishable from “no such bucket”:
| Mode | Anonymous caller | Another org’s authenticated caller | Owner read/write |
|---|---|---|---|
BUCKET_ACCESS_MODE_PRIVATE (default) | denied | denied | ✅ |
BUCKET_ACCESS_MODE_PUBLIC | reads allowed, writes denied | reads allowed, writes denied | ✅ |
BUCKET_ACCESS_MODE_CUSTOM | evaluated against the Bucket Policy; no policy = denied | evaluated against the Bucket Policy | ✅ (owner always has full access) |
UNSPECIFIED is treated as PRIVATE. “Write” means PutObject / DeleteObject; GetObject, HeadObject and ListBucket are reads.
Access mode is only evaluated on
api.data.dodil.io. The S3 byte plane atobject.uk-lon-1.dodil.iorefuses any request that carries no recognised credential with401 AccessDeniedbefore a bucket is ever looked at, and it exposes no/orgs/route. Anonymous, mode-aware access is the control-plane S3 route:
https://api.data.dodil.io/orgs/<org_name>/<bucket>/<key>The org slug and bucket name are resolved as a pair, so a mismatch returns S3 NoSuchBucket rather than leaking another tenant’s bucket. Path segments the control plane reserves for management endpoints — objects, policy, sources, rules, ingest, search, metadata, indexes, vector-store — are never resolved as object keys on this route.
See S3 Compatibility → Public buckets for end-to-end SDK examples.
Object
Type: ObjectInfo. The metadata view returned by the admin surface (ListObjects, GetObjectInfo). The bytes themselves flow through the K3 HTTP gateway, not the gRPC API.
HTTP
{
"bucket": "demo",
"key": "docs/sample.pdf",
"size": "1048576",
"etag": "\"d41d8cd98f00b204e9800998ecf8427e\"",
"contentType": "application/pdf",
"lastModified": "1700000500000",
"userMetadata": {
"source": "drive"
},
"storageClass": "STANDARD",
"indexStatus": "indexed",
"pipelineStatuses": [
{
"ruleId": "rule_01HEFG",
"ruleName": "docs-rule",
"pipelineKind": "vector",
"indexStatus": "indexed",
"destinationId": "col_01HIJK"
}
]
}Notes:
pipeline_statusesis the bridge between the storage plane and the ingestion plane: each entry tells you which rule fired for this object, what kind of pipeline ran, and the current index state.pipeline_kindis a flat string ("vector"/"warehouse"/"free") so callers can stay decoupled from the ingest service types.
Policy
Type: BucketPolicy. S3-style access control, modeled on the AWS bucket-policy contract. Bound to a bucket via SetBucketPolicy; BucketAccessMode.CUSTOM on Bucket signals a policy is in effect.
HTTP
{
"version": "2024-01-01",
"statements": [
{
"sid": "PublicRead",
"effect": "POLICY_EFFECT_ALLOW",
"principal": { "aws": ["*"] },
"actions": ["s3:GetObject"],
"resources": ["public/*"]
}
]
}Notes:
actionsare matched exactly against five recognised names:s3:GetObject,s3:PutObject,s3:DeleteObject,s3:ListBucket,s3:HeadObject. There is no wildcard action —s3:*matches nothing, and a statement left with no recognised action is dropped from the policy entirely.resourcesare bare object-key globs (*and?), not bucket-qualified paths and not ARNs:public/*openspublic/…whileprivate/*stays closed on the same bucket. Omittingresourcesdefaults to*.principal.awsaccepts"*"for anonymous access or one or more org IDs for tenant-scoped policies.- Evaluation is the S3 algorithm: an explicit
POLICY_EFFECT_DENYwins, any survivingALLOWgrants, and no matching statement is an implicit deny. - A policy is only consulted when
access_modeisCUSTOM. Setting one on aPUBLICorPRIVATEbucket changes nothing until you flip the mode. - The HTTP door also accepts an AWS-shaped document —
Version/Statement/Sid/Effect: "Allow"/Principal: {"AWS": "*"}/Action/Resourcewith ARNs (arn:aws:s3:::demo/public/*is stripped topublic/*), a bare string wherever a list is allowed, and a single statement object instead of an array. An ARN naming a different bucket is a400, not a silent no-match.Condition,NotAction,NotResourceandNotPrincipalare refused with a400naming the key — K3 does not evaluate them, and honouring the document while ignoring them would grant more access than it states.
CORS
Type: BucketCorsConfiguration. Mirrors S3 CORS one-to-one.
HTTP
{
"corsRules": [
{
"allowedOrigins": ["https://app.example.com"],
"allowedMethods": ["GET", "PUT"],
"allowedHeaders": ["*"],
"exposeHeaders": ["ETag"],
"maxAgeSeconds": 3600
}
]
}Notes:
- Rules are evaluated in order; the first rule matching origin (and, on a preflight, method and requested headers) wins and supplies every injected header.
allowed_originssupports*and a single wildcard segment —https://*.example.commatches by prefix + suffix. Exact matches are case-insensitive.- An empty
allowed_methodsmatches any method, per S3’s permissive behaviour. Be explicit. expose_headersis what a browser is allowed to read off the response —ETagis not CORS-safelisted, so an upload flow that reads the etag must list it.- Per-bucket CORS is enforced on
api.data.dodil.ioonly. That door answers theOPTIONSpreflight from these rules and injectsAccess-Control-Allow-Originon the response. Theobject.uk-lon-1.dodil.iobyte plane carries one fixed fleet-wide origin allow-list instead (localhost,dodil.io,dodil.cloudby default) and never consultsBucketCorsConfiguration. Point browser traffic that needs your own origin at the control-plane route. - These rules are K3’s own. The S3
?corssubresource passes through to the storage backend and is never read by K3 — see S3 Compatibility.
PresignedURL
Produced by the GetObjectUrl RPC. The URL carries a K3 custom token signed with the server’s presign_secret (impl: services/storage/objects.rs). It is not an AWS SigV4 or SigV2 URL.
HTTP
// GET /demo/objects/docs%2Fsample.pdf/url?expires_in_seconds=1800
// — key is URL-encoded in the path; there is no request body.
// Response
{
"url": "https://object.uk-lon-1.dodil.io/demo/docs/sample.pdf?X-K3-Token=9f3c…&X-K3-Expires=1700003600&X-K3-Org=org_01HABC",
"expiresAt": "1700003600000"
}Notes:
expires_in_secondsdefaults to 3600 (1h) and is capped at 86400 (24h) by the handler — ask for more and you get 24 h.expires_atin the response is Unix milliseconds;X-K3-Expiresin the URL is Unix seconds.- The URL carries three query parameters, all required:
X-K3-Token(HMAC-SHA256 hex),X-K3-Expires,X-K3-Org. The HMAC covers{org, bucket, key, expires}together, so the URL is bound to exactly one object — editing the key invalidates it. - The host is whatever the deployment sets as its public S3 proxy URL; in production that is the object endpoint (
object.uk-lon-1.dodil.io), which verifiesX-K3-Tokenwith the same shared verifier the control-plane route uses. Both doors accept it. - GET only. It grants a download, never an upload — browser uploads need a SigV4 presign instead (see Recipes → Browser upload).
- AWS SigV4 / SigV2 are separately accepted by the S3 proxy as incoming auth schemes — those are client-signed URLs.
GetObjectUrlis the server-issued, K3-token flow.
See also
- Object Storage — overview
- API Reference
- CLI Guide
- Conventions — auth, headers, error envelope
- CLI Basics — install + common flags