Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions scenarios/90-admin-tailnet-demo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Scenario 90: Admin Tailnet Demo

This scenario runs a small app with an auth-gated administration portal, a declared-scope authz role manager, and a Tailscale sidecar.

- App: <http://localhost:18080/>
- Admin: <http://localhost:18080/admin>
- Authz admin contribution: <http://localhost:18080/admin/authz>
- Status API: <http://localhost:18080/api/status>

Demo users:

- `admin@tailnet` / `admin`: full admin and frontend scopes.
- `readonly-admin@tailnet` / `readonly`: admin read scopes only.
- `app-user@tailnet` / `user`: frontend scopes only.

The authz contribution displays frontend and admin scopes from the declared scope catalog, including owner plugin/module metadata. The demo defaults to `AUTHZ_PROVIDER=keto`, runs a local Ory Keto container, and resolves role assignments into Keto scope relationship checks for the app/admin surfaces.

## Run

```sh
docker compose up -d --build
```

If `TS_AUTHKEY` is present, the sidecar joins the tailnet as `workflow-admin-demo` and publishes the app with `tailscale serve`.

Without `TS_AUTHKEY`, the app still runs locally. On a host already connected to Tailscale:

```sh
tailscale serve --bg --http=18080 http://127.0.0.1:18080
```

## Test

```sh
./test/run.sh
```

## Stop

```sh
docker compose down
tailscale serve reset
```
7 changes: 7 additions & 0 deletions scenarios/90-admin-tailnet-demo/app/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
FROM python:3.14-slim

WORKDIR /app
COPY app.py /app/app.py

EXPOSE 8080
CMD ["python", "/app/app.py"]
1,537 changes: 1,537 additions & 0 deletions scenarios/90-admin-tailnet-demo/app/app.py

Large diffs are not rendered by default.

50 changes: 50 additions & 0 deletions scenarios/90-admin-tailnet-demo/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
services:
app:
build:
context: ./app
container_name: workflow-admin-tailnet-demo
environment:
AUTHZ_PROVIDER: ${AUTHZ_PROVIDER:-keto}
KETO_READ_URL: http://keto:4466
KETO_WRITE_URL: http://keto:4467
ports:
- "127.0.0.1:18080:8080"
depends_on:
- keto
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/api/status')"]
interval: 5s
timeout: 3s
retries: 10

keto:
image: oryd/keto:v0.14.0
container_name: workflow-admin-tailnet-demo-keto
command: serve -c /etc/keto/keto.yml all
volumes:
- ./keto:/etc/keto:ro
ports:
- "4466"
- "4467"

tailscale:
image: tailscale/tailscale:stable
container_name: workflow-admin-tailnet-demo-ts
environment:
TS_AUTHKEY: ${TS_AUTHKEY:-}
TS_HOSTNAME: workflow-admin-demo
TS_STATE_DIR: /var/lib/tailscale
volumes:
- tailscale-state:/var/lib/tailscale
cap_add:
- NET_ADMIN
- NET_RAW
devices:
- /dev/net/tun:/dev/net/tun
command: sh -c 'if [ -n "$$TS_AUTHKEY" ]; then tailscaled --state=$$TS_STATE_DIR/tailscaled.state & sleep 2 && tailscale up --authkey=$$TS_AUTHKEY --hostname=$$TS_HOSTNAME && tailscale serve --bg --http=80 http://app:8080 && wait; else echo "TS_AUTHKEY not set; sidecar idle" && sleep infinity; fi'
depends_on:
app:
condition: service_healthy

volumes:
tailscale-state:
12 changes: 12 additions & 0 deletions scenarios/90-admin-tailnet-demo/keto/keto.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
dsn: memory

serve:
read:
host: 0.0.0.0
port: 4466
write:
host: 0.0.0.0
port: 4467

namespaces:
location: file:///etc/keto/namespaces.ts
17 changes: 17 additions & 0 deletions scenarios/90-admin-tailnet-demo/keto/namespaces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Namespace } from "@ory/keto-namespace-types"

class user implements Namespace {}

class scope implements Namespace {
related: {
granted: user[]
}
}

class resource implements Namespace {
related: {
owner: user[]
viewer: user[]
"delegated-admin": user[]
}
}
89 changes: 89 additions & 0 deletions scenarios/90-admin-tailnet-demo/test/playwright-authz-qa.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
set -euo pipefail

BASE="${BASE:-http://127.0.0.1:18080}"
ARTIFACT_DIR="${ARTIFACT_DIR:-/tmp/workflow-admin-authz-qa}"
ADMIN_JAR="$(mktemp)"
USER_JAR="$(mktemp)"
ADMIN_STATE="$(mktemp)"
USER_STATE="$(mktemp)"
trap 'rm -f "$ADMIN_JAR" "$USER_JAR" "$ADMIN_STATE" "$USER_STATE"' EXIT

mkdir -p "$ARTIFACT_DIR"

login() {
local jar="$1"
local email="$2"
local password="$3"
curl -fsS -c "$jar" -d "email=$email&password=$password" "$BASE/login" >/dev/null
}

storage_state() {
local jar="$1"
local output="$2"
local token
token="$(awk '$6 == "wf_demo_session" { print $7 }' "$jar" | tail -n 1)"
if [ -z "$token" ]; then
echo "missing wf_demo_session cookie" >&2
exit 1
fi
cat >"$output" <<JSON
{"cookies":[{"name":"wf_demo_session","value":"$token","domain":"127.0.0.1","path":"/","httpOnly":true,"secure":false,"sameSite":"Lax"}],"origins":[]}
JSON
}

login "$ADMIN_JAR" "admin@tailnet" "admin"
storage_state "$ADMIN_JAR" "$ADMIN_STATE"

playwright screenshot \
--load-storage "$ADMIN_STATE" \
--wait-for-selector '[role="tablist"]' \
--full-page \
"$BASE/admin/auth" \
"$ARTIFACT_DIR/admin-auth-desktop.png" >/dev/null

playwright screenshot \
--load-storage "$ADMIN_STATE" \
--wait-for-selector 'text=Google client secret' \
--full-page \
"$BASE/admin/auth?group=oauth_providers" \
"$ARTIFACT_DIR/admin-auth-oauth.png" >/dev/null

playwright screenshot \
--load-storage "$ADMIN_STATE" \
--viewport-size "390,844" \
--wait-for-selector 'text=Password login' \
"$BASE/admin/auth" \
"$ARTIFACT_DIR/admin-auth-mobile.png" >/dev/null

playwright screenshot \
--load-storage "$ADMIN_STATE" \
--wait-for-selector '[role="tablist"]' \
--full-page \
"$BASE/admin/authz" \
"$ARTIFACT_DIR/admin-authz-desktop.png" >/dev/null

playwright screenshot \
--load-storage "$ADMIN_STATE" \
--wait-for-selector 'form[action="/admin/authz/rebac/upsert"]' \
--full-page \
"$BASE/admin/authz?tab=rebac" \
"$ARTIFACT_DIR/admin-authz-management.png" >/dev/null

playwright screenshot \
--load-storage "$ADMIN_STATE" \
--viewport-size "390,844" \
--wait-for-selector '.scope-picker' \
"$BASE/admin/authz" \
"$ARTIFACT_DIR/admin-authz-mobile.png" >/dev/null

login "$USER_JAR" "app-user@tailnet" "user"
storage_state "$USER_JAR" "$USER_STATE"

playwright screenshot \
--load-storage "$USER_STATE" \
--wait-for-selector 'text=Forbidden' \
"$BASE/admin" \
"$ARTIFACT_DIR/frontend-user-forbidden.png" >/dev/null

echo "Playwright authz QA screenshots written to $ARTIFACT_DIR"
Loading
Loading