feat(auth): organization units, tenancy, roles and scopes for team access (#2919)
Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Executable
+91
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
# Boot an isolated Kandev instance with authentication enabled and seed a small
|
||||
# team, so workspace visibility and membership can be clicked through.
|
||||
#
|
||||
# The instance is deliberately hermetic: `env -i` plus an explicit
|
||||
# KANDEV_HOME_DIR, because an inherited KANDEV_DATABASE_PATH points at the
|
||||
# developer's real database.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PORT="${KANDEV_DEMO_PORT:-8231}"
|
||||
HOME_DIR="${KANDEV_DEMO_HOME:-/tmp/kandev-team-access-demo}"
|
||||
# Bind loopback plus any extra host (e.g. a Tailscale IP) so the instance is
|
||||
# reachable from another device on the tailnet. Seeding always talks to
|
||||
# loopback; BASE is only what the seeder and probes use.
|
||||
# Loopback is always bound: the health probe and the seeder talk to BASE on
|
||||
# 127.0.0.1, so an extra host must be added to loopback rather than replace it.
|
||||
BIND_HOSTS="127.0.0.1${KANDEV_DEMO_BIND:+,${KANDEV_DEMO_BIND}}"
|
||||
BASE="http://127.0.0.1:${PORT}"
|
||||
COOKIE_JAR="${HOME_DIR}/admin-cookies.txt"
|
||||
|
||||
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
|
||||
|
||||
api() { # api METHOD PATH [JSON]
|
||||
local method="$1" path="$2" body="${3:-}"
|
||||
if [ -n "$body" ]; then
|
||||
curl -sS -b "$COOKIE_JAR" -c "$COOKIE_JAR" -X "$method" \
|
||||
-H 'Content-Type: application/json' -d "$body" "${BASE}${path}"
|
||||
else
|
||||
curl -sS -b "$COOKIE_JAR" -c "$COOKIE_JAR" -X "$method" "${BASE}${path}"
|
||||
fi
|
||||
}
|
||||
|
||||
start_backend() {
|
||||
# Refuse to boot onto an occupied port. Without this the health probe below
|
||||
# happily passes against a STALE server still holding the port, and every
|
||||
# later check silently measures the old binary.
|
||||
if ss -lptn "sport = :${PORT}" 2>/dev/null | grep -q LISTEN; then
|
||||
echo "port ${PORT} is already in use; run '$0 stop' or free it first" >&2
|
||||
ss -lptn "sport = :${PORT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -rf "$HOME_DIR"
|
||||
mkdir -p "$HOME_DIR"
|
||||
log "booting kandev on ${BASE} (home ${HOME_DIR})"
|
||||
env -i \
|
||||
HOME="$HOME_DIR" \
|
||||
PATH="${REPO_ROOT}/apps/backend/bin:/usr/local/bin:/usr/bin:/bin" \
|
||||
KANDEV_HOME_DIR="$HOME_DIR" \
|
||||
KANDEV_PORT="$PORT" \
|
||||
KANDEV_SERVER_HOST="$BIND_HOSTS" \
|
||||
KANDEV_FEATURES_AUTH=true \
|
||||
KANDEV_FEATURES_OFFICE="${KANDEV_DEMO_OFFICE:-false}" \
|
||||
KANDEV_FEATURES_MULTI_TENANCY="${KANDEV_DEMO_TENANCY:-false}" \
|
||||
KANDEV_WEB_DIST_DIR="${REPO_ROOT}/apps/web/dist" \
|
||||
"${REPO_ROOT}/apps/backend/bin/kandev" __backend \
|
||||
>"${HOME_DIR}/backend.log" 2>&1 &
|
||||
echo $! >"${HOME_DIR}/backend.pid"
|
||||
|
||||
for _ in $(seq 1 90); do
|
||||
if curl -sf "${BASE}/health" >/dev/null 2>&1; then
|
||||
log "backend is up (pid $(cat "${HOME_DIR}/backend.pid"))"
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "backend did not become healthy; last log lines:" >&2
|
||||
tail -40 "${HOME_DIR}/backend.log" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
seed() {
|
||||
log "seeding the team"
|
||||
python3 "${REPO_ROOT}/scripts/demo_team_access_seed.py" "$BASE"
|
||||
}
|
||||
|
||||
case "${1:-up}" in
|
||||
up) start_backend; seed ;;
|
||||
stop)
|
||||
# Kill by the port's actual listener, not just the pid file: a crashed or
|
||||
# replaced launch leaves the pid file naming a process that is not the one
|
||||
# serving requests.
|
||||
for pid in $(ss -lptn "sport = :${PORT}" 2>/dev/null | grep -oP 'pid=\K[0-9]+' | sort -u); do
|
||||
if tr '\0' ' ' <"/proc/${pid}/environ" 2>/dev/null | grep -q "$HOME_DIR"; then
|
||||
kill "$pid" 2>/dev/null || true
|
||||
log "stopped pid ${pid}"
|
||||
fi
|
||||
done
|
||||
;;
|
||||
*) echo "usage: $0 [up|stop]" >&2; exit 2 ;;
|
||||
esac
|
||||
Executable
+169
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Seed a Kandev instance with a small team so workspace visibility and
|
||||
membership can be clicked through.
|
||||
|
||||
Idempotent: re-running against an already-seeded instance reuses the existing
|
||||
accounts and workspaces instead of failing on "email is already in use".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from http.cookiejar import CookieJar
|
||||
|
||||
PASSWORD = "kandev-demo-pw-1"
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, base: str) -> None:
|
||||
self.base = base.rstrip("/")
|
||||
self.opener = urllib.request.build_opener(
|
||||
urllib.request.HTTPCookieProcessor(CookieJar())
|
||||
)
|
||||
|
||||
def call(self, method: str, path: str, body: dict | None = None) -> dict:
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
request = urllib.request.Request(
|
||||
f"{self.base}{path}", data=data, method=method,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with self.opener.open(request, timeout=30) as response:
|
||||
raw = response.read().decode()
|
||||
except urllib.error.HTTPError as error:
|
||||
raw = error.read().decode()
|
||||
try:
|
||||
return {"_error": json.loads(raw).get("error", raw), "_status": error.code}
|
||||
except json.JSONDecodeError:
|
||||
return {"_error": raw, "_status": error.code}
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
def unwrap_id(payload: dict) -> str | None:
|
||||
"""Accept either {"id": ...} or {"user": {"id": ...}}."""
|
||||
if "id" in payload:
|
||||
return payload["id"]
|
||||
for key in ("user", "workspace", "task"):
|
||||
nested = payload.get(key)
|
||||
if isinstance(nested, dict) and "id" in nested:
|
||||
return nested["id"]
|
||||
return None
|
||||
|
||||
|
||||
def ensure_user(client: Client, email: str, name: str, role: str) -> str:
|
||||
created = client.call(
|
||||
"POST", "/api/v1/users",
|
||||
{"email": email, "password": PASSWORD, "display_name": name, "role": role},
|
||||
)
|
||||
user_id = unwrap_id(created)
|
||||
if user_id:
|
||||
return user_id
|
||||
# Already present: find it in the directory.
|
||||
for user in client.call("GET", "/api/v1/users").get("users", []):
|
||||
if user.get("email") == email:
|
||||
return user["id"]
|
||||
raise SystemExit(f"could not create or find {email}: {created}")
|
||||
|
||||
|
||||
def ensure_workspace(client: Client, name: str) -> str:
|
||||
for workspace in client.call("GET", "/api/v1/workspaces").get("workspaces", []):
|
||||
if workspace.get("name") == name:
|
||||
return workspace["id"]
|
||||
created = client.call("POST", "/api/v1/workspaces", {"name": name})
|
||||
workspace_id = unwrap_id(created)
|
||||
if not workspace_id:
|
||||
raise SystemExit(f"could not create workspace {name}: {created}")
|
||||
return workspace_id
|
||||
|
||||
|
||||
def first_workflow(client: Client, workspace_id: str) -> str | None:
|
||||
payload = client.call("GET", f"/api/v1/workspaces/{workspace_id}/workflows")
|
||||
flows = payload.get("workflows") if isinstance(payload, dict) else payload
|
||||
if isinstance(flows, list) and flows:
|
||||
return flows[0].get("id")
|
||||
return None
|
||||
|
||||
|
||||
def main() -> None:
|
||||
base = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8231"
|
||||
client = Client(base)
|
||||
|
||||
setup = client.call(
|
||||
"POST", "/api/v1/auth/setup",
|
||||
{"email": "ana@example.com", "password": PASSWORD, "display_name": "Ana Ferreira"},
|
||||
)
|
||||
if "_error" in setup:
|
||||
login = client.call(
|
||||
"POST", "/api/v1/auth/login",
|
||||
{"email": "ana@example.com", "password": PASSWORD},
|
||||
)
|
||||
if "_error" in login:
|
||||
raise SystemExit(f"cannot authenticate as the admin: {login}")
|
||||
print("signed in as Ana (admin)")
|
||||
|
||||
bruno = ensure_user(client, "bruno@example.com", "Bruno Costa", "member")
|
||||
carla = ensure_user(client, "carla@example.com", "Carla Nunes", "member")
|
||||
dana = ensure_user(client, "dana@example.com", "Dana Vieira (contractor)", "guest")
|
||||
print("colleagues ready: Bruno (member), Carla (member), Dana (guest)")
|
||||
|
||||
team = ensure_workspace(client, "Platform Team")
|
||||
private = ensure_workspace(client, "Ana - security spike")
|
||||
|
||||
# Build the shape a company actually has: a department with a team under
|
||||
# it, and the shared board placed in the team. Reach follows that tree, so
|
||||
# nobody is invited to the board itself.
|
||||
units = client.call("GET", "/api/v1/units")["units"] or []
|
||||
root = next(u["id"] for u in units if u["kind"] == "root")
|
||||
dept = client.call("POST", "/api/v1/units", {"parent_id": root, "name": "Platform"})
|
||||
squad = client.call("POST", "/api/v1/units", {"parent_id": dept["id"], "name": "Runtime"})
|
||||
client.call("PATCH", f"/api/v1/workspaces/{team}", {"unit_id": squad["id"]})
|
||||
client.call("PUT", f"/api/v1/units/{dept['id']}/members/{bruno}", {"role": "collaborator"})
|
||||
print("Platform > Runtime holds the shared board; Bruno reaches it through the department")
|
||||
|
||||
client.call("PUT", f"/api/v1/units/{squad['id']}/members/{carla}", {"role": "viewer"})
|
||||
print("Carla is a viewer on the Runtime team")
|
||||
|
||||
client.call("PUT", f"/api/v1/workspaces/{private}/members/{dana}", {"role": "collaborator"})
|
||||
print("Dana (guest) admitted to the private workspace only")
|
||||
|
||||
workflow = first_workflow(client, team)
|
||||
if workflow:
|
||||
for title in (
|
||||
"Rate-limit the public search endpoint",
|
||||
"Flaky checkout test on CI",
|
||||
"Upgrade the Postgres driver",
|
||||
):
|
||||
client.call("POST", "/api/v1/tasks", {
|
||||
"title": title, "workspace_id": team,
|
||||
"workflow_id": workflow, "start_agent": False,
|
||||
})
|
||||
print("seeded 3 tasks on the shared board")
|
||||
|
||||
summary = f"""
|
||||
Kandev team-access demo
|
||||
URL: {base}
|
||||
|
||||
Platform Team {team} visibility = org
|
||||
Ana - security spike {private} visibility = private
|
||||
|
||||
Sign in (password for all: {PASSWORD}):
|
||||
ana@example.com admin owns both workspaces
|
||||
bruno@example.com member sees Platform Team with NO invitation (collaborator)
|
||||
carla@example.com member narrowed to VIEWER on Platform Team
|
||||
dana@example.com guest sees ONLY the private workspace she was added to
|
||||
"""
|
||||
print(summary)
|
||||
# Honour KANDEV_DEMO_HOME: hardcoding the default made a second instance on
|
||||
# another port write into (or fail against) the first one's directory.
|
||||
home = os.environ.get("KANDEV_DEMO_HOME", "/tmp/kandev-team-access-demo")
|
||||
os.makedirs(home, exist_ok=True)
|
||||
with open(os.path.join(home, "demo-info.txt"), "w", encoding="utf-8") as handle:
|
||||
handle.write(summary)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user