# Credential Revocation — Cross-Replica Guide

GrantFlow supports credential revocation via `POST /agents/credentials/revoke`.
The scope of enforcement depends on how the deployment is configured.

---

## How revocation works today

```
POST /agents/credentials/revoke
```

When the request is processed:

1. The JTI (JWT ID) of the revoked credential is added to an **in-process denylist** on that replica.
2. If `GRANTFLOW_REDIS_URL` is set, the JTI is also written to **Redis** with the same TTL as the credential.
3. Every subsequent auth check calls `_credential_jti_revoked()`, which checks in-process first, then Redis.

The revoke response tells you which scope is active:

```json
{
  "status": "revoked",
  "revocation_scope": "redis_and_in_process",
  "restart_safe": true
}
```

or, without Redis:

```json
{
  "status": "revoked",
  "revocation_scope": "in_process",
  "restart_safe": false
}
```

---

## Deployment scenarios

### Single replica, no Redis

Revocation is in-process only. It is lost on process restart.
Acceptable for development and single-instance staging.

### Multiple replicas, Redis configured

Each replica checks the same Redis key on every auth call.
Revocation propagates to all replicas within the next request cycle.
**This is the recommended production configuration.**

Set `GRANTFLOW_REDIS_URL=redis://your-redis:6379/0` on all replicas.

### Multiple replicas, no Redis (not recommended)

Revocation only affects the replica that processed the `POST /revoke` request.
Other replicas continue to accept the credential until their process restarts or
until the credential's natural TTL expires.

To mitigate: route `POST /agents/credentials/revoke` through the gateway to all replicas,
or enforce revocation at the gateway layer (see below).

---

## Gateway-layer revocation (defence-in-depth)

Even with Redis configured, a gateway-layer denylist provides an extra enforcement point
that does not depend on the application layer reaching Redis.

### Pattern: Nginx auth_request + shared denylist sidecar

```
Client  →  Nginx (auth_request)  →  Denylist sidecar  →  GrantFlow
```

The denylist sidecar exposes a single endpoint that checks a shared Redis SET:

```python
# denylist_sidecar.py (FastAPI example)
import redis, os
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()
r = redis.from_url(os.environ["REDIS_URL"])
DENYLIST_KEY = "grantflow:revoked_jtis"

@app.get("/check")
def check(request: Request):
    # Extract JTI from Bearer token without full validation
    # (full validation is done by GrantFlow itself)
    auth = request.headers.get("Authorization", "")
    if auth.startswith("Bearer "):
        import base64, json
        try:
            parts = auth.removeprefix("Bearer ").split(".")
            payload = json.loads(base64.b64decode(parts[1] + "=="))
            jti = payload.get("jti", "")
            if jti and r.sismember(DENYLIST_KEY, jti):
                raise HTTPException(401, "revoked")
        except (IndexError, ValueError, KeyError):
            pass  # malformed tokens are rejected by GrantFlow
    return {"status": "ok"}
```

Wire it into Nginx:

```nginx
location / {
    auth_request /denylist-check;
    proxy_pass http://grantflow:8000;
}

location = /denylist-check {
    internal;
    proxy_pass http://denylist-sidecar:9000/check;
    proxy_pass_request_body off;
    proxy_set_header Content-Length "";
}
```

When `POST /revoke` is called, GrantFlow writes the JTI to Redis
(key: `grantflow:revoked_jti:<jti>`). To also populate the gateway denylist SET,
run a Redis keyspace notification listener or a simple sync job:

```bash
# One-liner to mirror GrantFlow's revoked JTIs into the gateway SET
redis-cli --no-auth-warning -u "$REDIS_URL" \
  KEYS "grantflow:revoked_jti:*" | \
  while read key; do
    jti="${key#grantflow:revoked_jti:}"
    redis-cli SADD grantflow:revoked_jtis "$jti"
  done
```

Or configure Redis keyspace notifications and subscribe to `__keyevent@0__:set`
filtered on the `grantflow:revoked_jti:` prefix.

### Pattern: Kong request-termination plugin

If you use Kong, add a plugin that checks the Redis SET before forwarding:

```yaml
plugins:
  - name: pre-function
    config:
      access:
        - |
          local redis = require "resty.redis"
          local r = redis:new()
          r:connect("redis", 6379)
          local auth = kong.request.get_header("authorization") or ""
          local token = auth:match("^Bearer (.+)$")
          if token then
            local parts = {}
            for p in token:gmatch("[^.]+") do parts[#parts+1] = p end
            if parts[2] then
              local ok, payload = pcall(require("cjson").decode,
                ngx.decode_base64(parts[2]))
              if ok and payload.jti then
                local member = r:sismember("grantflow:revoked_jtis", payload.jti)
                if member == 1 then
                  return kong.response.exit(401, {message = "revoked"})
                end
              end
            end
          end
```

---

## Recommended production checklist

- [ ] Set `GRANTFLOW_REDIS_URL` on all GrantFlow replicas
- [ ] Confirm `POST /revoke` returns `"revocation_scope": "redis_and_in_process"`
- [ ] Route `POST /agents/credentials/revoke` to a single replica or all replicas (idempotent)
- [ ] (Optional) Add gateway-layer denylist sidecar for defence-in-depth
- [ ] Test: revoke a credential on replica A, verify rejection on replica B

---

## Related docs

- `docs/deployment/oauth-gateway.md` — OAuth provider integration
- `docs/enterprise-access-layer.md` — role model and gateway policy
- `AGENTS.md` — revocation endpoint reference
- `docs/agents/trust-report.md` — what GrantFlow enforces vs. gateway responsibility
