Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
100 changes: 100 additions & 0 deletions HUMANS.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,106 @@ woltspace init # creates bob

All wolts share one container. `woltspace.json` tracks which wolt is active. Auth is shared — after the first wolt authenticates, new ones reuse the token.

## Multi-user permissions (optional)

By default a woltspace container is single-tenant — anyone past the tunnel
sees and controls every wolt. To gate per user (collaborators, family,
small group), there's an opt-in mode that uses your existing Cloudflare
Access setup.

### Setup

1. **Seed yourself** into `wolts/.space/auth/users.json` so you don't
lock yourself out. From inside the container (or via a wolt session
using the `woltspace-access` skill):
```bash
access add you@example.com '*'
```
The `'*'` is a wildcard meaning "every wolt."

2. **Enable auth** in `~/.woltspace/wolts/.env`:
```bash
WOLTSPACE_AUTH=cloudflare
WOLTSPACE_CF_TEAM_DOMAIN=yourteam.cloudflareaccess.com
WOLTSPACE_CF_AUD=<application audience tag from CF Zero Trust>
```

3. **Restart** the server. Visit the lodge — Cloudflare Access asks for
email OTP, the JWT lands at the server, the middleware validates it
and looks up your email in `users.json`.

### Adding collaborators

Two steps:

1. Add their email to the Cloudflare Access policy
(Zero Trust → Access → Applications → policies → emails) so they
can reach the tunnel.

2. Add them to `users.json`:
```bash
access add bob@example.com bloggo shared-wolt
```

`users.json` looks like:

```json
{
"users": [
{"email": "you@example.com", "wolts": ["*"]},
{"email": "collaborator@example.com", "wolts": ["bloggo"]}
]
}
```

Users see and control only wolts in their allow-list. Apps inherit
access from their keeper wolt. A user can also create new wolts via the
lodge UI — when they do, the new wolt is auto-appended to their own
allow-list (self-onboarding).

### Accessing via localhost (WOLTSPACE_AUTH_TRUST_LOCAL)

When auth is on, requests must carry a Cloudflare Access JWT — which only
exists for traffic that came through the tunnel (`yourname.woltspace.com`).
Hitting `localhost:7777` directly has **no JWT**, so by default you'll see
no wolts. That's the secure default: no Cloudflare login = no identity.

In-container tools (the `notify`, `push-view`, `access` CLIs) still work —
they originate from genuine loopback (127.0.0.1) and are always trusted.

If you want plain `localhost` browser access to work while auth is on, set:

```bash
WOLTSPACE_AUTH_TRUST_LOCAL=true
```

This trusts callers on the private network (including your host browser,
which Docker presents as the bridge gateway address) and grants them full
wildcard access without a JWT.

> ⚠️ **Only enable this on a network you trust.** The container publishes
> its port with `-p 7777:7777` (binds `0.0.0.0`), so the port is reachable
> from your whole LAN — and every such caller appears as the same private
> address inside the container. There is no way to distinguish your own
> browser from another device on the network. With this flag on, **anyone
> who can reach `your-machine-ip:7777` on your LAN gets unauthenticated
> full access.** Leave it off if your machine is on shared/untrusted wifi;
> use the tunnel URL instead.

Default is OFF. Remote users always go through the tunnel + Cloudflare
Access regardless of this setting.

### Scope of enforcement

This is application-layer. The lodge UI shows only what the user is
allowed to see, and the REST API refuses cross-wolt requests. It does
**not** stop a session that's already running from reading another
wolt's files via the shell — all sessions still run as the same OS
user. That's tracked separately (filesystem isolation, issue #354).

Default mode is `WOLTSPACE_AUTH=none` — single-tenant, today's behavior,
zero configuration. Skip this whole section if that's what you want.

## Messaging (Telegram, etc.)

Wolts can talk through messaging apps. Add config to `~/.woltspace/wolts/.env`:
Expand Down
182 changes: 182 additions & 0 deletions container/bin/access
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""Manage woltspace user permissions — edits wolts/.space/auth/users.json.

This is a convenience CLI. It is NOT a security boundary — anyone with shell
access in the container can write the underlying file directly. Real
enforcement requires filesystem-level isolation (issue #354). See HUMANS.md.

Usage:
access list
access add EMAIL [WOLTS...] — add a user with an allow-list (use "*" for all wolts)
access grant EMAIL WOLTS... — append wolts to an existing user's allow-list
access revoke EMAIL WOLTS... — remove wolts from a user's allow-list
access remove EMAIL — delete a user entry entirely
access check EMAIL WOLT — does EMAIL have access to WOLT?
"""

import json
import os
import sys
import time
from pathlib import Path


WOLTS_DIR = Path(os.environ.get("WOLTS_DIR", "/workspace/wolts"))
USERS_FILE = WOLTS_DIR / ".space" / "auth" / "users.json"


def load() -> list[dict]:
if not USERS_FILE.exists():
return []
try:
data = json.loads(USERS_FILE.read_text())
return data.get("users", []) if isinstance(data, dict) else []
except json.JSONDecodeError as e:
die(f"users.json is corrupt: {e}")


def save(users: list[dict]) -> None:
USERS_FILE.parent.mkdir(parents=True, exist_ok=True)
USERS_FILE.write_text(json.dumps({"users": users}, indent=2) + "\n")


def die(msg: str, code: int = 1) -> None:
print(f"error: {msg}", file=sys.stderr)
sys.exit(code)


def find(users: list[dict], email: str) -> dict | None:
email = email.lower()
for u in users:
if (u.get("email") or "").lower() == email:
return u
return None


def fmt_user(u: dict) -> str:
wolts = u.get("wolts") or []
allow = "all" if "*" in wolts else (", ".join(wolts) if wolts else "(none)")
return f" {u['email']:<35} wolts: {allow}"


# --- Subcommands ---

def cmd_list(_args: list[str]) -> None:
users = load()
if not users:
print("(no users yet — run 'access add EMAIL *' to add the first one)")
return
print(f"users ({len(users)}):")
for u in users:
print(fmt_user(u))


def cmd_add(args: list[str]) -> None:
if not args:
die("usage: access add EMAIL [WOLTS...]")
email = args[0].strip().lower()
wolts = list(args[1:])
users = load()
if find(users, email):
die(f"{email} already exists — use 'grant' to add wolts")
users.append({
"email": email,
"wolts": wolts,
"added_at": int(time.time()),
})
save(users)
print(f"added {email} with wolts: {wolts or '(none)'}")


def cmd_grant(args: list[str]) -> None:
if len(args) < 2:
die("usage: access grant EMAIL WOLTS...")
email = args[0].strip().lower()
new_wolts = args[1:]
users = load()
u = find(users, email)
if not u:
die(f"no such user: {email}. use 'add' to create.")
allow = u.get("wolts") or []
if "*" in allow:
die(f"{email} already has wildcard access ('*') to everything")
for w in new_wolts:
if w not in allow:
allow.append(w)
u["wolts"] = allow
save(users)
print(f"granted {email}: {', '.join(new_wolts)}")
print(fmt_user(u))


def cmd_revoke(args: list[str]) -> None:
if len(args) < 2:
die("usage: access revoke EMAIL WOLTS...")
email = args[0].strip().lower()
remove = set(args[1:])
users = load()
u = find(users, email)
if not u:
die(f"no such user: {email}")
u["wolts"] = [w for w in (u.get("wolts") or []) if w not in remove]
save(users)
print(f"revoked from {email}: {', '.join(remove)}")
print(fmt_user(u))


def cmd_remove(args: list[str]) -> None:
if not args:
die("usage: access remove EMAIL")
email = args[0].strip().lower()
users = load()
before = len(users)
users = [u for u in users if (u.get("email") or "").lower() != email]
if len(users) == before:
die(f"no such user: {email}")
save(users)
print(f"removed {email}")


def cmd_check(args: list[str]) -> None:
if len(args) < 2:
die("usage: access check EMAIL WOLT")
email = args[0].strip().lower()
wolt = args[1]
users = load()
u = find(users, email)
if not u:
print(f"{email} → NOT IN users.json (denied)")
sys.exit(2)
allow = u.get("wolts") or []
if "*" in allow or wolt in allow:
print(f"{email} → ALLOWED for {wolt}")
sys.exit(0)
print(f"{email} → DENIED for {wolt} (allow-list: {allow})")
sys.exit(2)


COMMANDS = {
"list": cmd_list,
"ls": cmd_list,
"add": cmd_add,
"grant": cmd_grant,
"revoke": cmd_revoke,
"remove": cmd_remove,
"rm": cmd_remove,
"check": cmd_check,
}


def main() -> None:
if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help", "help"):
print(__doc__)
sys.exit(0)
cmd, *args = sys.argv[1:]
fn = COMMANDS.get(cmd)
if not fn:
die(f"unknown command: {cmd}. try: {', '.join(sorted(set(COMMANDS.keys())))}")
fn(args)


if __name__ == "__main__":
main()
Loading