# Tenancy and auth

Lockwell is multi-tenant by design. An app maps each of its customers (an org, a workspace, a project) to a **tenant**,
and every tenant is isolated: separate buckets, separate keys, separate quota.

This page covers the model (tenants, scoped keys, native bearer tokens, signed URLs, admin tokens) and the security
posture that backs it.

## The model at a glance

| Concept                           | What it is                                       | Issued by                     | Used for                                      |
| --------------------------------- | ------------------------------------------------ | ----------------------------- | --------------------------------------------- |
| **Tenant**                        | An isolated namespace (your "org")               | Admin API / app kit           | Containing one customer's buckets and objects |
| **Access key**                    | `accessKeyId` + `secretKey`, scoped to a tenant  | Admin API / app kit           | S3 SigV4, and minting native tokens           |
| **Native bearer token** (`lwtk_`) | Short-lived token minted from an access key      | The native client (auto)      | The native JSON data plane                    |
| **Native signed URL**             | A method/resource/TTL-bound URL, no token needed | A native client               | Browser-direct upload/download                |
| **Admin token** (`lwadm_`)        | Bearer token with an RBAC role                   | `lockwell admin-token create` | The JSON Admin API                            |

## Tenants

A tenant is the unit of isolation. `provisionTenant` treats an existing tenant as success, then mints a fresh scoped
key. Use it for onboarding or deliberate key creation, and store the returned secret immediately. For ordinary sign-in
requests, load the tenant's stored credentials instead of provisioning again.

::: code-group

```ts [Node]
const { tenantId, created, key } = await kit.provisionTenant("acme", {
  name: "Acme Inc",
  defaultBucket: "inbox",
});
```

```go [Go]
res, err := kit.ProvisionTenant(ctx, "acme", lockwellkit.ProvisionTenantInput{
 Name:          "Acme Inc",
 DefaultBucket: "inbox",
})
```

```java [Java]
ProvisionResult res = kit.provisionTenant("acme",
    new LockwellKit.ProvisionOptions().tenantName("Acme Inc").defaultBucket("inbox"));
```

:::

Map your customer's id to a Lockwell tenant id (e.g. `org_acme` becomes tenant `acme`). A credential's tenant is derived
server-side from the credential itself, never from a request path, so one customer's request can never reach another's
data. See [isolation](#tenant-isolation-comes-from-the-credential) below. Tenants are disabled or deleted through the
Admin API (reason required, retention and legal-hold gated). See the [Admin API reference](/reference/admin-api).

## Scoped access keys

An access key is an `accessKeyId` + `secretKey` bound to a tenant. The **secret is returned exactly once** on create and
on rotate. Lockwell stores only the encrypted secret and can never show it again, so persist it in your own datastore
(encrypted) the moment you receive it.

::: warning The secret is shown once There is no read-back endpoint for a secret. If you lose it, rotate the key to mint
a new one. Capture `accessKeyId` and `secretKey` from the create or rotate response before doing anything else. :::

Keys carry a **scope** in a small policy grammar: per-operation verbs plus optional bucket/prefix narrowing.

```text
read,write,delete                          # full data-plane access for the tenant
op=read:bucket=inbox,op=write:bucket=inbox,op=delete:bucket=inbox
                                           # the same, restricted to one bucket
op=read:bucket=inbox:prefix=incoming/      # read-only, one bucket, one prefix
```

The four verbs are `read`, `write`, `delete`, and `admin`. The app kit's `provisionTenant` mints a **data** key
(`read,write,delete`, optionally bucket-scoped). It never hands back a key carrying the `admin` verb, so a provisioned
key cannot manage buckets or policy. (When you ask the kit to create a default bucket, it mints a transient
`admin`-scoped key for that one bucket and revokes it immediately.)

The Java kit adds a retry-safe ERP bootstrap path, `ensureTenantProvisioning`, for installers that cannot tolerate
duplicate live keys after a retry. Set `.keyExternalRef("<external-installation-or-purpose-id>")`; the kit lists active
key metadata and reuses a matching `externalRef + scopes + expiry` key without re-exposing the secret. If the secret was
lost before the ERP stored it, rotate that returned key with an audit reason and persist the new one-time secret from
the rotation response; omitted rotate scopes/expiry preserve the old key's values. Create/rotate/revoke key calls accept
an audit reason, and the Admin API emits `X-Request-Id` while storing that request id as audit correlation.

For ERP company/purpose layouts, keep an ERP-side mapping table with opaque `tenantPublicRef`, `installationRef`, and
`lockwellTenantId` values. Java `ErpScopes` derives `companies/<opaque-company-ref>/<purpose>/` prefixes, key
`externalRef` values, and purpose-scoped access-key templates; bucket names and prefixes must not contain customer legal
names. Store the access key id per purpose so support can rotate/revoke by purpose, then drop the ERP credential cache
after rotation. Tests should assert the generated prefix on every object path so cross-company mistakes are visible.

### The scope grammar in detail

A scope string is one of two forms:

- **Verb list.** A comma-separated subset of `read`, `write`, `delete`, `admin` applied to the whole tenant. Example:
  `read,write`.
- **Qualified form.** `op=<verb>:bucket=<bucket>:prefix=<prefix>` where `bucket` and `prefix` narrow the verb. `prefix`
  requires `bucket`. Use one clause per verb, separated by commas. Example:
  `op=read:bucket=uploads:prefix=tenants/acme/,op=write:bucket=uploads:prefix=tenants/acme/`.

Which verb each operation needs:

| Operation                                                       | Verb     |
| --------------------------------------------------------------- | -------- |
| GET, HEAD, list objects/versions, get tags/retention/legal-hold | `read`   |
| PUT, copy, multipart, set tags, set retention/legal-hold        | `write`  |
| delete object, batch delete, abort multipart                    | `delete` |
| create/delete bucket, set versioning, set notifications         | `admin`  |

The same scope is enforced on **every** surface. A read-only key cannot write over S3, the native API, or a signed URL.

### Create, rotate, revoke, expire

The key lifecycle runs through the Admin API (or the app kit's underlying admin client). Every mutation accepts `dryRun`
to preview without applying, and the destructive ones (`revokeKey`) take a required, audited `reason`.

::: code-group

```ts [Node]
// Create a precisely-scoped key. The secret is returned once.
const k = await admin.createKey("acme", {
  scopes: "op=read:bucket=uploads:prefix=incoming/,op=write:bucket=uploads:prefix=incoming/",
  expiresAt: "2026-12-31", // RFC-3339, YYYY-MM-DD, or "never"
});
console.log(k.accessKeyId, k.secretKey); // store both now

// Rotate: revoke this key and mint a replacement. Restate the scope you want
// kept (an empty rotate resets it to the server default).
const rotated = await admin.rotateKey("acme", k.accessKeyId, {
  scopes: "op=read:bucket=uploads:prefix=incoming/,op=write:bucket=uploads:prefix=incoming/",
});
console.log(rotated.accessKeyId, rotated.secretKey, rotated.oldAccessKeyId);

// Change expiry / scope later (admin UI or API). Then revoke when retired.
await admin.revokeKey("acme", k.accessKeyId, { reason: "employee offboarded" });
```

```go [Go]
nk, _, err := admin.CreateKey(ctx, "acme", lockwelladmin.CreateKeyInput{
 Scopes:    "op=read:bucket=uploads:prefix=incoming/,op=write:bucket=uploads:prefix=incoming/",
 ExpiresAt: "2026-12-31",
})
// nk.AccessKeyID, nk.SecretKey. Store both now.

rotated, _, err := admin.RotateKey(ctx, "acme", nk.AccessKeyID, lockwelladmin.RotateKeyInput{
 Scopes: "op=read:bucket=uploads:prefix=incoming/,op=write:bucket=uploads:prefix=incoming/",
})
// rotated.AccessKeyID + rotated.SecretKey are new; rotated.OldAccessKeyID is revoked.

_, _, err = admin.RevokeKey(ctx, "acme", nk.AccessKeyID,
 lockwelladmin.RevokeKeyInput{Reason: "employee offboarded"})
```

```java [Java]
NewKey k = admin.createKey("acme",
    new CreateKeyOptions(null,
        "op=read:bucket=uploads:prefix=incoming/,op=write:bucket=uploads:prefix=incoming/",
        "2026-12-31"));
// k.key().accessKeyId(), k.secretKey(). Store both now.

NewKey rotated = admin.rotateKey("acme", k.key().accessKeyId(),
    new RotateKeyOptions(
        "op=read:bucket=uploads:prefix=incoming/,op=write:bucket=uploads:prefix=incoming/",
        null));
// rotated.key().accessKeyId() + rotated.secretKey() are new; rotated revokes the old.

admin.revokeKey("acme", k.key().accessKeyId(), "employee offboarded");
```

:::

Notes on each:

- **Create** returns the secret once. There is no read-back.
- **Rotate** revokes the old key and mints a replacement, so the new `accessKeyId` and secret both change and the
  response reports `oldAccessKeyId`. The previous key stops authenticating at once. Restate the scope you want on the
  new key, since an empty rotate falls back to the server default. Store the new pair, then swap your app over to it.
- **Expiry** is a wall-clock cutoff (`expiresAt`). After it passes, the key fails closed on every surface. An expired
  key cannot mint a native token, and any outstanding native token it minted fails closed too.
- **Revoke** is immediate for an in-process admin mutation (the access-key cache is busted) and propagates within the
  cache TTL (about 5s) for an out-of-process CLI revoke. A revoked key fails closed everywhere.

## Native bearer tokens (`lwtk_`)

The native data plane authenticates with a short-lived bearer token, minted from an access key. You never pass a token
in, only the key.

The native client manages the token for you. It mints on first use (`POST /api/v1/auth/token`), caches it until shortly
before expiry, refreshes transparently, and re-mints once on a `401`. Minting is concurrency-safe (single-flight), so a
burst of requests shares one in-flight mint instead of one per request.

The token is stateless and signed:

```text
lwtk_<base64url(payloadJSON)>.<base64url(HMAC-SHA256(payload, signingKey))>
```

The payload carries `{accessKeyId, tenantId, accountId, scopes, iat, exp}`. The signing key is derived from the
deployment's at-rest master key via **HKDF** under a dedicated label, so it is per-deployment and never configured
separately. The HMAC is compared in **constant time**. Two consequences matter:

- **The tenant comes from the token, not the request.** A native call's tenant is whatever the signed token says. There
  is no tenant in the URL path to spoof.
- **Revocation is honored promptly.** After the (DB-free) signature and expiry check, the middleware re-resolves the
  underlying access key through the same cached lookup the SigV4 path uses. A revoked or expired key, or a disabled
  tenant, **fails closed with `401`** even though the token's signature is still valid. The token can never outlive the
  key it points at, nor out-scope it.

You almost never construct a token by hand. Hand the client your access key and let it manage the token. The secret and
the live token are never logged or rendered (`toString`/`inspect`/JSON all redact them). The token TTL is set by
`security.native_api_token_ttl` (default 1h).

## Native signed URLs

A signed URL grants a single, **method- and resource-bound**, time-limited operation with **no bearer token**. It is the
way to hand a browser a direct upload or download. The URL carries its authorization in a `token` query parameter, is
bound to one bucket+key and one method, and **can never exceed the minting key's scope** (the server re-checks scope and
bucket policy both at mint time and at access time). A read-only key asked to sign a `PUT` is denied `403`.

The S3 client presigns **GET, PUT, HEAD, and DELETE**. The native API signs **GET and PUT**, and its constrained signed
`PUT` is the sanctioned browser-upload path:

::: code-group

```ts [Node]
const up = await kit.signedUploadUrl(creds, "inbox", "photo.jpg", { ttlSeconds: 300, contentType: "image/jpeg" });
const down = await kit.signedDownloadUrl(creds, "inbox", "photo.jpg", { ttlSeconds: 300 });
```

```go [Go]
up, _ := kit.SignedUploadURL(ctx, creds, "inbox", "photo.jpg",
 lockwellkit.SignedUploadURLInput{TTLSeconds: 300, ContentType: "image/jpeg"})
down, _ := kit.SignedDownloadURL(ctx, creds, "inbox", "photo.jpg", 300)
```

```java [Java]
BrowserSignedUrl up = kit.signedUploadUrl(creds, "inbox", "photo.jpg",
    Duration.ofMinutes(5), "image/jpeg");
BrowserSignedUrl down = kit.signedDownloadUrl(creds, "inbox", "photo.jpg",
    Duration.ofMinutes(5));
```

:::

TTL is clamped server-side to the deployment's maximum (`security.max_presign_ttl`). See
[Signed URLs](/guide/signed-urls) for the browser-side handling.

## Admin tokens (`lwadm_`) and RBAC

The Admin API authenticates with an **admin API token**, distinct from access keys. It is a high-entropy bearer secret
minted **offline** on the server host (there is no JSON route to mint the first token; the bootstrap is filesystem-trust
only) and stored **hashed** (SHA-256), never in plaintext:

```sh
lockwell admin-token create --role operator             # cluster-wide operator
lockwell admin-token create --role viewer --tenant acme # read-only, one tenant
```

Authorization composes the **RBAC role** with an optional **single-tenant scope**:

| Role       | Can                                                             |
| ---------- | --------------------------------------------------------------- |
| `owner`    | Everything, including admin-token and admin-user management     |
| `operator` | Tenant/key/quota lifecycle and audit (no admin-user management) |
| `viewer`   | Read-only (list/get tenants, keys, quota, usage, audit)         |

A **tenant-scoped** token is forced to its own tenant server-side. A request targeting another tenant is a `403`, never
a `404` existence leak. Bearer tokens are not sent automatically by browsers, so the Admin API is server-to-server and
carries no CSRF flow. Every request, success and denial, is audited.

```ts
import { AdminClient } from "@kelphect/sdk";

const admin = new AdminClient({
  endpoint: "http://localhost:9001", // admin listener, not the public S3 port
  token: process.env.LOCKWELL_ADMIN_TOKEN, // Authorization: Bearer lwadm_...
});
await admin.listTenants();
```

Keep the admin token server-side only. Provisioning runs in your backend. The browser never sees an admin token or an
access-key secret, only short-lived signed URLs.

## How an app maps customers to tenants

A typical multi-tenant backend:

1. On customer onboarding, call `provisionTenant(<your customer id>)` once. Store the returned
   `{ accessKeyId, secretKey }` against that customer, encrypted.
2. On each request, look up the customer's creds and get a per-tenant native client (`clientForTenant`). It is cached
   per `(tenant, creds)` and reuses one token manager.
3. For browser I/O, mint a short-lived signed URL and return only the URL.
4. The customer id in **your** URL paths is a lookup key into **your** store. It is never a cross-tenant vector on
   Lockwell, because the acting tenant is always derived from the stored creds' token.

The runnable [Go service example](https://github.com/RusticStack/lockwell-sdk-go/tree/main/examples/go-service) models exactly
this shape. See also [App kit](/guide/app-kit).

## Security posture

### Tenant isolation comes from the credential

The tenant is always taken from the signed credential, never from the request path, so cross-tenant access is
structurally impossible. Another tenant's bucket simply does not exist for your token (returned as `404`, never a leak).
The same holds across all three surfaces.

### Always-on, fail-closed

- **Encryption at rest is always on.** Every object, written through any surface, is encrypted, deduped, quota-checked,
  and retention-gated through the same object pipeline.
- **Constant-time** comparisons guard token HMACs and webhook signatures. Secrets and live tokens are never logged or
  rendered.
- **Fail-closed by default.** Anonymous and unauthenticated requests, and revoked or expired credentials, are denied
  (`401`). A delete blocked by retention or a legal hold returns `412`. A tenant over quota returns `507`.

### Deliberate non-goals

To keep the trust surface small, Lockwell deliberately does not offer:

- **No public or anonymous buckets.** There is no access without a token or a signed URL.
- **No SSE-KMS.** Encryption at rest is server-managed by default (SSE-S3-style). The S3 wire API and all three
  first-party S3 clients expose typed SSE-C and copy-source SSE-C helpers. Native/admin APIs do not accept SSE-C keys.
- **No IAM/STS.** There is no policy/role/temporary-credential service. Scoping is done with the key scope grammar
  above.
- **Webhook-only notifications.** SNS/SQS/Lambda targets return `501`. See [Webhooks](/guide/webhooks).

## Next steps

- [The three surfaces](/guide/the-three-surfaces). Where each credential applies.
- [Signed URLs](/guide/signed-urls) and [Webhooks](/guide/webhooks). Browser-direct and event paths.
- [Admin API reference](/reference/admin-api). The full tenant/key/quota/audit surface.
