Docs/Advanced Features/ACLs & Keychains

    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

    TypeIdentifierUse Case
    userEVM address, Solana address, or emailHuman users authenticated via wallet or email
    serviceAPI key IDBackend 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:

    1. Explicit principal passed to the SDK constructor or operation
    2. Principal resolved from the keychain's active identity
    3. 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"] }
        ]
      }
    }
    
    FieldDescription
    defaultPolicy"deny" (recommended) or "allow". Applied when no grant matches.
    principalsNamed registry of principals. Referenced by name in model and entity ACLs.
    grantsOrdered 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 acl is omitted from a model, bucket-level grants apply.
    • Model-level ACL narrows bucket-level access -- a principal needs both.

    Relationship with mode:

    modeacl.write: ["admin"]Result
    readonly(irrelevant)No writes for anyone. mode wins.
    readwriteadmin onlyOnly admin can write.
    appendadmin onlyOnly 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

    VariableDescription
    $principalThe authenticated Principal object
    $principal.idPrincipal ID string
    $principal.type"user", "service", or "anonymous"
    $principal.rolesString array of roles
    $principal.identityIdentity object (for user principals)
    $principal.identity.addressWallet address (EVM/Solana)
    $principal.identity.emailEmail address
    Path parametersAvailable by name from dynamic segments (userId, teamId, slug, etc.)

    Operators

    OperatorDescriptionExample
    ==EqualityuserId == $principal.identity.address
    !=Inequality$principal.type != 'anonymous'
    containsArray/string contains$principal.roles contains 'admin'
    startsWithString prefix match$principal.id startsWith 'user:'
    &&Boolean ANDuserId == $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

    BackendPlatformStorageEncryption
    BrowserKeychainStorageBrowserIndexedDBAES-256-GCM via Web Crypto (PBKDF2 derived key)
    NodeKeychainStorageNode.js~/.worm/keychain.encAES-256-GCM via node:crypto
    MemoryKeychainStorageAnyIn-memory MapNone (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