# Part 7: Token Permission Model and Roles

**Previous:** [`docs/06-oauth.md`](06-oauth.md)

---

## Overview

Packr tokens carry fine-grained permissions. Every token has a **role** (a preset bundle of permissions) and an optional set of **scope restrictions** (limiting which npm scopes the token can access). This allows you to issue least-privilege tokens for CI pipelines, automated tools, and individual contributors.

---

## Permission Types

| Permission | What it allows |
|------------|----------------|
| `read` | Download and install packages, fetch metadata |
| `publish` | Publish new package versions |
| `unpublish` | Remove published versions (within 72-hour window) |
| `admin` | Full access: read, publish, unpublish, manage tokens, manage users |

Permissions are checked **inside each handler**, by a direct
`auth.HasPermission(claims, perm)` call — not by middleware. The
`RequirePermission` middleware exists at `internal/auth/permissions.go:125` and
is covered by its own unit tests, but no production route mounts it. Don't
assume the framework gates a route for you.

---

## Preset Roles

Roles are named bundles of permissions. Use roles instead of specifying individual permissions to avoid mistakes.

| Role | Permissions | Typical Use Case |
|------|-------------|------------------|
| `ci-readonly` | `read` | GitHub Actions install step, `pnpm install` in CI |
| `ci-publish` | `read`, `publish` | GitHub Actions publish step, automated release |
| `maintainer` | `read`, `publish`, `unpublish` | Package maintainers who can release and retract |
| `admin` | All | Registry administrators, dashboard access |

---

## Scope Restrictions

A token can be restricted to one or more npm scopes. When scope restrictions are set, the token can only operate on packages within those scopes.

- **Empty scopes** — token applies to all packages (backward compatible)
- **One or more scopes** — token is restricted to exactly those scopes

Examples:

| Token scopes | Can access |
|-------------|------------|
| *(empty)* | `@myorg/pkg-a`, `@other/pkg-b`, any package |
| `@myorg` | Only `@myorg/*` packages |
| `@myorg`, `@internal` | Only `@myorg/*` and `@internal/*` packages |

Scope restrictions are enforced at the route level for publish, unpublish, deprecate, and dist-tag operations.

---

## JWT Claims Structure

Every access token is a JWT. The claims include the user identity and the token's permissions:

```go
type TokenClaims struct {
    OrgID       string   `json:"org_id"`
    UserID      string   `json:"user_id"`
    Scope       string   `json:"scope,omitempty"`       // legacy single scope
    PkgLimit    int      `json:"pkg_limit"`
    Permissions []string `json:"permissions,omitempty"` // e.g. ["read","publish"]
    Scopes      []string `json:"scopes,omitempty"`      // scope restrictions
    jwt.RegisteredClaims
}
```

There is deliberately **no `role` claim**: the preset role lives only on the
`tokens` row in the database, and the JWT carries the *expanded*
`permissions`. The two cannot drift because the claim was never there to
drift. Web-issued tokens store the user as `web:<id>` (e.g. `web:14`).

**Example decoded JWT payload:**

```json
{
  "org_id": "default",
  "user_id": "web:14",
  "permissions": ["read", "publish"],
  "scopes": ["@blueforge-studio"],
  "exp": 1746000000,
  "iat": 1743408000
}
```

---

## Token Creation

### Via API

```
POST /api/v1/admin/users/me/tokens
Authorization: Bearer <jwt>
Content-Type: application/json

{
  "name": "ci-publish-token",
  "role": "ci-publish",
  "scopes": ["@blueforge-studio"],
  "expires_in_days": 30
}
```

The endpoint mints a token **for the authenticated user** (`user_id` claim →
stored as `web:<id>`). Optional body fields beyond the four above:
`ecosystem` + `namespace` (compose to an `eco:namespace` scope) and `groups`
(named scope groups, expanded at mint time).

**Response** (200 OK):
```json
{
  "id": 42,
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "name": "ci-publish-token",
  "role": "ci-publish",
  "permissions": ["read", "publish"],
  "scopes": ["@blueforge-studio"]
}
```

The raw `token` value is only returned at creation time. Store it immediately.
The response carries no `expires_at`/`created_at` — decode the JWT's `exp`
claim for expiry.

### Via CLI

```bash
# Create a read-only CI token scoped to @myorg
packr-cli token create --name ci-read --role ci-readonly --scope @myorg

# Create a publish token scoped to @myorg
packr-cli token create --name ci-publish --role ci-publish --scope @myorg

# Create an admin token (no scope restriction)
packr-cli token create --name admin-token --role admin

# Create with custom expiry (90 days)
packr-cli token create --name long-lived --role ci-readonly --expires 90
```

---

## Token List Output

```
$ packr-cli token list

ID    Name                      Role          Scopes              Created    Expires   Last used
42    ci-publish-token          ci-publish    [@blueforge-studio] 2026-04-10 unknown   never
43    ci-read-token             ci-readonly   [@blueforge-studio] 2026-04-10 unknown   2026-04-11
1     admin-token               admin         *                   2026-04-10 unknown   2026-04-12
```

`Expires` fills in from the row's `expires_at` column, which the mint and
reissue paths persist to mirror the JWT's `exp` claim (same defaulting —
30 days unless `expires_in_days` says otherwise). Rows minted before the
column was populated carry NULL and show `unknown`: decode those tokens
(or run `packr-cli token verify` / `doctor`) to read their expiry from the
claim. `Last used` fills in as the registry sees the token.

---

## Backward Compatibility

Existing tokens without `permissions` or `role` claims keep working — but they
are **not** admin tokens.

- `HasPermission` (`read`, `publish`, `unpublish`) treats a nil permission list
  as *everything*.
- `HasExplicitPermission` (`admin`) grants a nil list **nothing**, so a
  claim-less token is not an administrator. That split exists because the nil
  fallback once let every logged-in user through four admin gates.

**Since #330 a nil permission list is read as a login session, and a session is
not a registry principal.** The publish, unpublish and read gates go through
`HasSessionAwarePermission`, which consults `AUTH_SESSION_READS_REGISTRY` for reads (writes use `HasSessionWritePermission` + `AUTH_SESSION_WRITES_REGISTRY`, default off)
(default `false`). With it off, a `/-/v1/login` session JWT satisfies none of
them and publishing or reading a private package requires a stored token row;
with it on, the old behaviour is restored exactly.

So: mint a real token when you want the credential to be revocable and
attributable, and — as of #330 — also when you want it to be able to publish at
all.

The permission check logic:

```go
// A nil permission list is a login session, not a legacy token.
if auth.IsSession(claims) && !cfg.SessionReadsRegistry {
    return false
}
// Otherwise: no permissions claim means grant all (HasPermission)
// Otherwise check the required permission
return slices.Contains(claims.Permissions, required)
```

---

## Token Lifecycle

### Default Expiry

Tokens expire after **30 days** by default. The OAuth device flow — which is
what `packr-cli login` uses — mints at **90 days** instead
(`internal/npm/device_flow.go:186`), so a session from the device flow is not a
30-day token. Set `expires_in_days` at creation time to override; nothing caps
it, so a large value is accepted as given.

Note the asymmetry: the JWT `exp` *is* validated when present (and re-checked on
the row-fallback path), but there is no `expires_at` column, so
`GET /api/v1/admin/users/me/tokens` cannot tell you when a token dies. A token
row outlives its `exp` claim in the database; only the claim is enforced.

| Use case | Recommended expiry |
|----------|--------------------|
| CI/CD tokens | 30-90 days |
| Developer tokens | 30 days |
| Long-lived service accounts | 90 days max |
| Emergency admin access | 1 day |

### Rotation Strategy

To rotate a CI token with zero downtime:

1. Create the new token:
   ```bash
   packr-cli token create ci-publish-v2 --role ci-publish --scope @myorg
   ```

2. Update the secret in your CI/CD system (GitHub, GitLab, etc.) with the new token value.

3. Verify pipelines are working with the new token.

4. Revoke the old token:
   ```bash
   packr-cli token revoke 42
   ```

This ensures there is no gap in CI access during rotation. **Always revoke last** —
`rotate` and `token create` both leave the existing credential working precisely so
that step 3 can fail safely.

#### Rotating without the token passing through a log

`token create` prints the token to stdout so it can be piped. That also means
anything running it — a CI job, a terminal transcript, an AI agent — captures a live
credential. Use `--output-file` when you would rather it never be displayed:

```bash
packr-cli token create ci-publish-v2 --role ci-publish --scope @myorg \
  --output-file "$f"
gh secret set PACKR_TOKEN < "$f" && rm -f "$f"
```

`packr-cli rotate` never prints a token at all — it writes the new value straight
into `~/.packr/credentials.json` and `~/.npmrc`. It also accepts `--output-file`,
for the common case where the rotation needs to end in a CI secret:

```bash
f=$(mktemp); trap 'rm -f "$f"' EXIT
packr-cli rotate --scope @myorg --name rotated-2026-09 --output-file "$f"
gh secret set PACKR_TOKEN < "$f"
packr-cli token list           # find the old id
packr-cli token revoke <old>   # only once the new one is confirmed working
```

Prefer `rotate` when replacing a credential that is already in use, and
`token create` when minting an additional one.

### Revoking a token you can no longer authenticate as

`packr-cli token revoke <id>` normally deletes the token as its owner. If that
returns 401 or 403, it usually does **not** mean you lack authority — it means your
own credential can no longer authenticate against the admin API. The common cause is
a token issued before the last `JWT_SECRET` rotation: publishing authenticates by
hash lookup in the `tokens` table and still works, while admin routes verify the JWT
signature and reject it. The credential most in need of revoking is then the one its
owner is least able to revoke.

The CLI detects this and retries through the operator-secret route:

```bash
packr-cli token revoke 19 --super-admin-token <token> --reason "exposed in a log"
# or set PACKR_SUPER_ADMIN_TOKEN
```

That path posts `{"id": N}` to `/api/v1/admin/tokens/revoke`, which deletes the row.
**Requirement:** the registry process must have `SUPER_ADMIN_TOKEN` set in its
runtime env — the handler compares in constant time and **fails closed** when
the server-side value is absent (`internal/npm/secret_compare.go`), so a
correct local secret still 401s against a registry that was never provisioned
with one. (App-3 carries it in `registry.secrets.env` / `registry.env` as of
2026-09-23; `X-Internal-Secret` is accepted by the same handler wherever that
one *is* set.)

Deleting is what actually stops a legacy token, because the hash lookup — not the
signature — is the path it authenticates on.

| Situation | Command |
|---|---|
| Ordinary revoke, your login works | `packr-cli token revoke <id>` |
| Token has a `jti` and you hold the value | `POST /api/v1/admin/tokens/revoke` with `{"token": "..."}` |
| Legacy token, no `jti`, admin API rejects you | `packr-cli token revoke <id> --super-admin-token ...` |
| **Minting a replacement** when your login is the thing that's broken | `packr-cli token create … --as-user web:<id> --output-file …` (operator route, `X-Super-Admin-Token`; see [05-cli](05-cli.md#operator-mint-as-user--mint-without-a-login)) |
| **Finding a stranded token's id** (owned by another user, in nobody's `token list`) | `GET /api/v1/admin/tokens` with `X-Super-Admin-Token` or `X-Internal-Secret` — every row, all owners, with `expires_at` and `last_used_at` for triage |

`GET /api/v1/admin/tokens` is the discovery half of the revoke pair above:
`/users/me/tokens` filters to the caller's own `web:<id>`, so a token minted
for someone else (the token-240 shape) was revokable-by-id but undiscoverable
without database access. Same shared-secret gate, same fail-closed behavior.

Revoking by `jti` adds the id to a denylist. Revoking by row id deletes the
credential outright. Tokens issued before `jti` claims existed have no id to deny,
so the row-id form is the only one that works on them — previously the documented
remedy was rotating `JWT_SECRET`, which invalidates every other token at the same
time.

---

## Best Practices for CI Token Management

**Separate read and publish tokens:**

```bash
# Read-only for install steps
packr-cli token create --name ci-install --role ci-readonly --scope @myorg

# Publish-capable for release steps
packr-cli token create --name ci-release --role ci-publish --scope @myorg
```

Store them as separate secrets:

```bash
gh secret set PACKR_TOKEN --org my-org < ci-install-token
gh secret set PACKR_TOKEN_PUBLISH --org my-org < ci-release-token
```

Use them in workflows:

```yaml
# Install step
- name: Install dependencies
  env:
    PACKR_TOKEN: ${{ secrets.PACKR_TOKEN }}
  run: pnpm install

# Publish step (release workflow only)
- name: Publish package
  env:
    PACKR_TOKEN: ${{ secrets.PACKR_TOKEN_PUBLISH }}
  run: npm publish
```

**Never share tokens across repositories.** Each repository or pipeline should have its own token so you can revoke access to one without affecting others.

**Scope tokens tightly.** A `ci-publish` token scoped to `@myorg` cannot
publish to `@other/*` or reach admin functions. Two caveats that make "tightly"
worth spelling out:

- **An unscoped package name bypasses the scope check entirely.**
  `registry_publish.go:51` only checks when the parsed scope is non-empty, so a
  token scoped to `@myorg` can still publish `somepackage` (no `@`).
- **A scope with an ecosystem but no namespace is ecosystem-wide.** `npm:`
  grants *every* npm namespace, because `Allows` returns true for an empty
  namespace. A bare `--scope @myorg` (the legacy form, no ecosystem) is the
  narrower of the two and is the one to prefer.

Verify what you actually minted: `packr-cli token create` echoes the stored
scopes, and `GET /-/v1/credential/verify?scope=` answers the scope question
separately from signature and ownership.

---

**Next:** [`docs/08-cli-auth.md`](08-cli-auth.md) — CLI Authentication and Credential Management
