ACLs and Keychains
S3WORM provides a layered access control system with client-side credential management. ACLs determine who can do what at the bucket, model, and entity level. The keychain stores credentials, identities, and encryption keys on the client side.
Principals
A principal is any identity that interacts with a WORM bucket. Every SDK operation runs on behalf of a principal. If none is provided, the operation runs as anonymous.
Principal Types
| Type | Identifier | Use Case |
|---|---|---|
user | EVM address, Solana address, or email | Human users authenticated via wallet or email |
service | API key ID | Backend services, CI/CD pipelines |
anonymous | "anonymous" | Public/unauthenticated access |
import type { Principal, UserPrincipal, ServicePrincipal, AnonymousPrincipal } from "@decoperations/s3worm";
// User principal with EVM wallet
const user: UserPrincipal = {
id: "user:0x1234abcd...",
type: "user",
roles: ["admin"],
identity: { method: "evm", address: "0x1234abcd..." },
};
// Service principal with API key
const service: ServicePrincipal = {
id: "service:sk_prod_xyz",
type: "service",
keyId: "sk_prod_xyz",
};
// Anonymous principal
const anon: AnonymousPrincipal = {
id: "anonymous",
type: "anonymous",
};
Principal Resolution Order
The SDK resolves the current principal in this order:
- Explicit principal passed to the SDK constructor or operation
- Principal resolved from the keychain's active identity
anonymous(fallback)
The resolved principal is attached to every OplogEntry.actor as a canonical string: user:0x1234..., service:sk_abc..., or anonymous.
ACL Layers
Access control is evaluated at three layers, from coarsest to finest. A request must pass all applicable layers.
Bucket-Level ACL
Declares who can access the bucket and sets the default policy. Lives in .worm/schema.json under a top-level acl key:
{
"acl": {
"defaultPolicy": "deny",
"principals": {
"admin": {
"type": "user",
"identity": { "method": "evm", "address": "0xABCD..." },
"roles": ["admin"]
},
"api-service": {
"type": "service",
"keyId": "sk_prod_..."
},
"public": {
"type": "anonymous"
}
},
"grants": [
{ "principal": "admin", "operations": ["*"] },
{ "principal": "api-service", "operations": ["read", "write"] },
{ "principal": "public", "operations": ["read"] }
]
}
}
| Field | Description |
|---|---|
defaultPolicy | "deny" (recommended) or "allow". Applied when no grant matches. |
principals | Named registry of principals. Referenced by name in model and entity ACLs. |
grants | Ordered list. First matching grant wins. Operations: read, write, delete, list, *. |
Model-Level ACL
Per-model permissions declared as an acl field on ModelDefinition:
{
"models": {
"Customer": {
"path": "#org/@customers/(id:uuid)",
"mode": "readwrite",
"acl": {
"read": ["admin", "api-service"],
"write": ["admin"],
"delete": ["admin"],
"list": ["admin", "api-service"]
}
},
"PublicPage": {
"path": "#site/@pages/(slug:string)",
"mode": "readwrite",
"acl": {
"read": ["*"],
"write": ["admin"],
"delete": ["admin"]
}
}
}
}
Semantics:
"*"means any principal (including anonymous).- An empty array
[]disables that operation at this layer. - If
aclis omitted from a model, bucket-level grants apply. - Model-level ACL narrows bucket-level access -- a principal needs both.
Relationship with mode:
| mode | acl.write: ["admin"] | Result |
|---|---|---|
readonly | (irrelevant) | No writes for anyone. mode wins. |
readwrite | admin only | Only admin can write. |
append | admin only | Only admin can create. No updates or deletes. |
Entity-Level Rules (Path-Scoped ACL)
Fine-grained, per-document permissions using path-scoped rules:
{
"rules": {
"user-owns-their-data": {
"applyTo": "#users/(userId:evm)/**",
"acl": "owner-only",
"scope": "userId == $principal.identity.address"
},
"team-shared-docs": {
"applyTo": "#teams/(teamId:uuid)/@docs/**",
"acl": "team-members",
"scope": "$principal.roles contains 'team:' + teamId"
},
"public-read-private-write": {
"applyTo": "#site/@posts/(slug:string)/**",
"acl": {
"read": ["*"],
"write": ["admin", "editor"]
}
}
}
}
Scope Expression Syntax
The scope field uses a limited expression language evaluated against path parameters and the authenticated principal:
Available Variables
| Variable | Description |
|---|---|
$principal | The authenticated Principal object |
$principal.id | Principal ID string |
$principal.type | "user", "service", or "anonymous" |
$principal.roles | String array of roles |
$principal.identity | Identity object (for user principals) |
$principal.identity.address | Wallet address (EVM/Solana) |
$principal.identity.email | Email address |
| Path parameters | Available by name from dynamic segments (userId, teamId, slug, etc.) |
Operators
| Operator | Description | Example |
|---|---|---|
== | Equality | userId == $principal.identity.address |
!= | Inequality | $principal.type != 'anonymous' |
contains | Array/string contains | $principal.roles contains 'admin' |
startsWith | String prefix match | $principal.id startsWith 'user:' |
&& | Boolean AND | userId == $principal.identity.address && $principal.roles contains 'editor' |
|| | Boolean OR | $principal.roles contains 'admin' || $principal.roles contains 'editor' |
+ | String concatenation | 'team:' + teamId |
Permission Evaluation Pipeline
Every SDK operation goes through this pipeline:
Request(operation, model, entityId?, principal)
|
+-- 1. Resolve principal (explicit -> keychain -> anonymous)
|
+-- 2. Check model.mode
| readonly + write op? -> DENY (ModeError)
| append + delete op? -> DENY (ModeError)
|
+-- 3. Check bucket-level ACL
| Evaluate grants for principal + operation
| No match? -> apply defaultPolicy
|
+-- 4. Check model-level ACL
| acl field present? -> principal in acl[operation]?
| No acl field? -> pass (bucket-level is sufficient)
|
+-- 5. Check entity-level rules (if entityId is known)
| Match applyTo patterns -> evaluate scope -> apply rule's ACL
|
+-- 6. ALLOW -> proceed, set OplogEntry.actor
Deny Reasons
Every denial is typed for programmatic handling:
import { AclDeniedError } from "@decoperations/s3worm";
try {
await customers.save(entity);
} catch (err) {
if (err instanceof AclDeniedError) {
console.log(err.reason.layer); // "mode" | "bucket" | "model" | "entity" | "token"
}
}
Dry-Run ACL Check
Check permissions without throwing:
const { allowed, reason } = await worm.checkAccess("Customer", "write", "abc-123");
if (!allowed) {
console.log("Denied by:", reason.layer);
}
Client-Side Keychain
The keychain is a client-side credential manager that stores S3 credentials, user identities, encryption keys, and scoped access tokens.
Setup
import { S3Worm, Keychain, MemoryKeychainStorage } from "@decoperations/s3worm";
// Create a keychain with a storage backend
const keychain = new Keychain({ storage: new MemoryKeychainStorage() });
await keychain.unlock("user-passphrase");
Storage Backends
| Backend | Platform | Storage | Encryption |
|---|---|---|---|
BrowserKeychainStorage | Browser | IndexedDB | AES-256-GCM via Web Crypto (PBKDF2 derived key) |
NodeKeychainStorage | Node.js | ~/.worm/keychain.enc | AES-256-GCM via node:crypto |
MemoryKeychainStorage | Any | In-memory Map | None (ephemeral, for tests and CI) |
Keychain Methods
Credentials
// Store S3 credentials for a bucket
await keychain.addCredentials("my-bucket", {
accessKeyId: "AKIA...",
secretAccessKey: "wJalr...",
endpoint: "https://s3.us-east-1.amazonaws.com",
region: "us-east-1",
});
// Retrieve credentials
const creds = await keychain.getCredentials("my-bucket");
// Remove credentials
await keychain.removeCredentials("my-bucket");
// List all buckets with stored credentials
const buckets = await keychain.listBuckets();
Identity
// Set the active identity
await keychain.setIdentity({
type: "evm",
identifier: "0x1234abcd...",
});
// Resolve the current principal from the stored identity
const principal = await keychain.resolvePrincipal();
// -> { id: "evm:0x1234abcd...", type: "user", identity: { method: "evm", address: "0x1234abcd..." } }
// Clear the active identity
await keychain.clearIdentity();
Scoped Tokens
// Store a scoped access token
await keychain.addToken("guest-read", tokenData);
// Retrieve a token
const token = await keychain.getToken("guest-read");
// List all stored token names
const names = await keychain.listTokens();
// Remove a token
await keychain.removeToken("guest-read");
Lifecycle
// Lock the keychain (clears in-memory secrets)
await keychain.lock();
// Unlock with passphrase
const success = await keychain.unlock("my-passphrase");
// Check lock status
keychain.isUnlocked(); // true | false
// Destroy all stored data
await keychain.destroy();
SDK Integration
import { S3Worm, Keychain, BrowserKeychainStorage } from "@decoperations/s3worm";
const keychain = new Keychain({ storage: new BrowserKeychainStorage() });
await keychain.unlock("user-passphrase");
// Store credentials once
await keychain.addCredentials("my-bucket", {
accessKeyId: "AKIA...",
secretAccessKey: "wJalr...",
endpoint: "https://s3.us-east-1.amazonaws.com",
});
// Set identity once
await keychain.setIdentity({
type: "evm",
identifier: "0x1234abcd...",
});
// Use with S3Worm -- keychain auto-resolves credentials and principal
const worm = new S3Worm({
bucket: "my-bucket",
keychain,
});
Without a keychain (backwards compatible):
const worm = new S3Worm({
bucket: "my-bucket",
credentials: {
accessKeyId: "AKIA...",
secretAccessKey: "wJalr...",
},
endpoint: "...",
});
// Operations run as "anonymous", ACLs are not enforced
Encrypted Fields
Per-field encryption for sensitive data at rest. Encrypted fields are stored as ciphertext in S3 and decrypted client-side.
Schema Declaration
{
"models": {
"Customer": {
"path": "#org/@customers/(id:uuid)",
"fields": {
"name": { "type": "string", "required": true },
"email": { "type": "string", "required": true, "encrypted": true },
"ssn": { "type": "string", "encrypted": true, "acl": ["admin"] },
"phone": { "type": "string", "encrypted": true }
}
}
}
}
Field-level acl: When a field has an acl array, only principals in that list can decrypt the field. Others see "[encrypted]".
How It Works
- Each model with encrypted fields has a dedicated AES-256-GCM key stored in the keychain
- On
save(), the SDK encrypts marked fields individually before writing - On
findById()/findAll(), the SDK decrypts marked fields after reading - Encrypted fields are stored as base64-encoded
[12-byte IV][ciphertext]strings
Key Rotation
await worm.model("Customer").rotateEncryptionKey(newKey);
// Lists all entities -> decrypts with old key -> re-encrypts with new key -> writes
CLI Commands
worm auth login Interactive login (EVM/Solana/email)
worm auth logout Clear the active identity
worm auth whoami Print the current principal
worm auth add-key <bucket> Add S3 credentials to keychain
worm auth remove-key <bucket> Remove stored credentials
worm auth list List stored credentials (names, not secrets)
worm auth lock Lock the keychain
worm auth unlock Unlock the keychain
worm acl check <model> [entity-id] Dry-run ACL evaluation for current principal
worm acl generate-policy Generate recommended S3 bucket policy