Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ OPENAI_API_KEY=
# TPK_EXTRACTION_BACKEND= # [llm].backend auto | claude | openai
# TPK_EXTRACTION_MODEL= # [llm].model semantic-extraction model
# TPK_CONFIG=repos.toml # path to the config file itself
# TPK_ANONYMOUS_ACCESS=0 # [server].anonymous_access 1 = unauthenticated chat over public entries
# TPK_ANONYMOUS_DAILY_TOKEN_LIMIT=100000 # [server].anonymous_daily_token_limit shared daily budget (0 = closed)
# TPK_MCP_HTTP_ENABLED=1 # [server].mcp_http false/0 disables the remote MCP endpoint (/mcp)
# TPK_MCP_ALLOWED_HOSTS= # [server].mcp_allowed_hosts comma-separated Host allow-list for /mcp (empty = no check)

Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ export the matching env var — whichever suits your deployment. Secrets are
| `TPK_SESSION_TTL` | `[server].session_ttl` | `86400` | Login session lifetime (seconds) |
| `TPK_CHAT_AUDIT` | `[server].chat_audit` | `true` | Chat Q&A auditing (`false`/`0` disables) |
| `TPK_DAILY_TOKEN_LIMIT` | `[server].daily_token_limit` | `500000` | Global fallback per-user daily token budget for non-admins (`0` = unlimited; a per-user or role `daily_token_limit` wins) |
| `TPK_ANONYMOUS_ACCESS` | `[server].anonymous_access` | `false` | Serve unauthenticated chat over the public corpus entries (chat only) |
| `TPK_ANONYMOUS_DAILY_TOKEN_LIMIT` | `[server].anonymous_daily_token_limit` | `100000` | One daily token budget shared by all anonymous chat (`0` = closed) |
| `TPK_MCP_HTTP_ENABLED` | `[server].mcp_http` | `true` | Remote MCP endpoint `/mcp` (`false`/`0` disables it) |
| `TPK_MCP_ALLOWED_HOSTS` | `[server].mcp_allowed_hosts` | `` | Comma-separated `Host` allow-list for `/mcp` (empty = no check) |
| `TPK_EXTRACTION_BACKEND` | `[llm].backend` | `auto` | Semantic-extraction backend (`auto`\|`claude`\|`openai`) |
Expand Down Expand Up @@ -650,6 +652,22 @@ with `users:view`) covers all of this — create/edit/delete users and roles,
tick capabilities and corpus entries by checkbox — without hand-writing
these requests; it is read-only for a `users:view`-only caller.

**Anonymous access (off by default).** Set `TPK_ANONYMOUS_ACCESS=1`
(`[server].anonymous_access`) and the login page offers **Continue without
signing in**: an unauthenticated visitor can chat over the corpus entries whose
visibility is `public` — and nothing else. The anonymous principal holds only
the `chat` capability (no Explorer, no source fragments or thinking trace, no
MCP, no management), its corpus scope is the enabled public entries (enforced
per entry, server-side, on every turn), and the agent's prompt lists only
those entries. All anonymous traffic shares **one** daily token budget,
`TPK_ANONYMOUS_DAILY_TOKEN_LIMIT` (`[server].anonymous_daily_token_limit`,
default 100000; `0` closes anonymous chat) — when it is spent the chat answers
429 with a "sign in for your own budget" hint. A wrong or expired token is
still rejected (401), never downgraded to anonymous. The names `anonymous`
(user and role) are reserved. Because a public entry is exposed to anyone once
this is on, the add-entry form warns when `public` is chosen; visibility
cannot be changed after creation.

**Break-glass (locked out of every admin account).** The API's last-admin
guard only stops you from doing this through `/api`; if every admin is
disabled, deleted, or its password is lost, there is no in-app way back. Run
Expand Down
2 changes: 2 additions & 0 deletions deploy/docker/repos.container.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ token_budget = 16000
# chat_audit = true # env: TPK_CHAT_AUDIT
# daily_token_limit = 500000 # env: TPK_DAILY_TOKEN_LIMIT (global fallback, non-admin per-user/day; 0 = unlimited)
# mcp_http = true # env: TPK_MCP_HTTP_ENABLED (false/0 disables the remote MCP endpoint /mcp)
# anonymous_access = false # env: TPK_ANONYMOUS_ACCESS (unauthenticated chat over public entries)
# anonymous_daily_token_limit = 100000 # env: TPK_ANONYMOUS_DAILY_TOKEN_LIMIT (shared by all anonymous chat; 0 = closed)
# mcp_allowed_hosts = "" # env: TPK_MCP_ALLOWED_HOSTS (comma-separated Host allow-list; empty = no check)

[repos.proton-enterprise]
Expand Down
1 change: 1 addition & 0 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ Login-based access control, with roles that scope what each user can query.
- **Role-scoped chat.** Orthogonal to capabilities: a role lists the exact `name@ref` corpus entries its members may query, and a non-admin's chat/explore results are transparently restricted to that scope (admin, the local stdio MCP server, and the CLI are unrestricted; the remote `/mcp` endpoint runs as the token's user and is scoped like chat). Isolation is enforced server-side across every graph tool path.
- **Bounded delegation.** A non-admin with `users:manage` can never mint admins or manage admin users, and can only grant capabilities and corpus entries within its own grant — no self-promotion path.
- **Admin console.** A Users/Roles console manages accounts, role assignments, password resets, per-role capabilities, and per-role entry-key access; last-admin lockout is prevented, and `tpk auth reset-admin` recovers the admin account from the command line if it happens anyway.
- **Anonymous access (opt-in).** With `TPK_ANONYMOUS_ACCESS` on, visitors can chat — and only chat — over the corpus entries marked `public`, under one shared daily token budget; internal entries stay invisible to them, enforced per entry on the server.
- **Hardened DB layer.** The compose stack provisions a dedicated `tpk` timeplusd user and password-locks the previously open `default` user.

## Deployment surfaces
Expand Down
2 changes: 2 additions & 0 deletions repos.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ token_budget = 16000
# chat_audit = true # env: TPK_CHAT_AUDIT (false/0 disables chat auditing)
# daily_token_limit = 500000 # env: TPK_DAILY_TOKEN_LIMIT (global fallback per-user/day, non-admin; 0 = unlimited; a per-user/role limit wins)
# mcp_http = true # env: TPK_MCP_HTTP_ENABLED (false/0 disables the remote MCP endpoint /mcp)
# anonymous_access = false # env: TPK_ANONYMOUS_ACCESS (unauthenticated chat over public entries)
# anonymous_daily_token_limit = 100000 # env: TPK_ANONYMOUS_DAILY_TOKEN_LIMIT (shared by all anonymous chat; 0 = closed)
# mcp_allowed_hosts = "" # env: TPK_MCP_ALLOWED_HOSTS (comma-separated Host allow-list; empty = no check)

[repos.proton-enterprise]
Expand Down
34 changes: 24 additions & 10 deletions src/tpk/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,20 +199,34 @@ def build_agent(
if corpus_provider is None:
return create_react_agent(chat_model, tools, prompt=system_prompt(repos))

# `repos` seeds the fallback corpus: if `corpus_provider()` raises (e.g.
# a transient DB failure), the turn must still get a usable prompt
# instead of the chat dying with an error event. Mirrors the
# degrade-gracefully pattern in tools.py's KnowledgeGraph._corpus_state.
return create_react_agent(chat_model, tools, prompt=live_prompt(repos, corpus_provider))


def live_prompt(repos, corpus_provider):
"""Per-turn prompt builder. `repos` seeds the fallback corpus: if
`corpus_provider()` raises (e.g. a transient DB failure), the turn must
still get a usable prompt instead of the chat dying with an error event.
Mirrors the degrade-gracefully pattern in tools.py's
KnowledgeGraph._corpus_state.

The corpus list is narrowed to the turn's ROLE_SCOPE (a role's entries,
or the anonymous public scope, #94): the same ContextVar the tools obey,
so the prompt never advertises entries the tools cannot reach."""
from tpk.tools import ROLE_SCOPE

last_good_repos = repos

def _live_prompt(state):
def _prompt(state):
nonlocal last_good_repos
try:
last_good_repos = corpus_provider()
except Exception:
pass # keep serving the last successful (or seed) corpus
return [
{"role": "system", "content": system_prompt(last_good_repos)}
] + list(state["messages"])

return create_react_agent(chat_model, tools, prompt=_live_prompt)
entries = (list(last_good_repos.values()) if isinstance(last_good_repos, dict)
else list(last_good_repos))
scope = ROLE_SCOPE.get()
if scope is not None:
entries = [r for r in entries if entry_key(r) in scope]
return [{"role": "system", "content": system_prompt(entries)}] + list(state["messages"])

return _prompt
4 changes: 4 additions & 0 deletions src/tpk/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,8 @@ def api_list_users(actor: User = Depends(auth.require_cap(auth_mod.CAP_USERS_VIE

@router.post("/users")
def api_add_user(body: AddUser, actor: User = Depends(auth.require_cap(auth_mod.CAP_USERS_MANAGE))):
if body.username == auth_mod.ANONYMOUS_USERNAME:
raise HTTPException(400, "'anonymous' is a reserved username")
if not _USERNAME_RE.match(body.username) or body.username in (".", ".."):
raise HTTPException(400, "username must match ^[A-Za-z0-9._-]+$")
err = auth_mod.validate_new_password(body.password)
Expand Down Expand Up @@ -484,6 +486,8 @@ def api_list_roles(actor: User = Depends(auth.require_cap(auth_mod.CAP_USERS_VIE
def api_upsert_role(body: UpsertRole, actor: User = Depends(auth.require_cap(auth_mod.CAP_USERS_MANAGE))):
if body.name == auth_mod.ROLE_ADMIN:
raise HTTPException(400, "'admin' is a reserved role name")
if body.name == auth_mod.ROLE_ANONYMOUS:
raise HTTPException(400, "'anonymous' is a reserved role name")
if not _USERNAME_RE.match(body.name):
raise HTTPException(400, "role name must match ^[A-Za-z0-9._-]+$")
if any(not isinstance(k, str) or not k for k in body.entry_keys):
Expand Down
76 changes: 67 additions & 9 deletions src/tpk/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@
log = logging.getLogger(__name__)

ROLE_ADMIN = "admin"
# The anonymous principal (#94): served for a request with NO credentials when
# TPK_ANONYMOUS_ACCESS is on. Like `admin`, its role is a sentinel and never a
# kg_roles row; unlike admin it holds exactly one capability (chat) and its
# corpus scope is the enabled PUBLIC entries (public_scope). Both names are
# reserved so no account or role can impersonate it.
ROLE_ANONYMOUS = "anonymous"
ANONYMOUS_USERNAME = "anonymous"
SEED_USERNAME = "admin"
SEED_PASSWORD = "changeme"

Expand Down Expand Up @@ -506,11 +513,14 @@ def _session_ttl() -> int:


def effective_capabilities(user: User, role: Role | None) -> set[str]:
"""The capabilities a user actually holds. `admin` gets all of them; any
other user gets the (view-expanded) capabilities of their role, or the
empty set if the role is missing/unreadable (fail closed)."""
"""The capabilities a user actually holds. `admin` gets all of them;
`anonymous` gets chat only; any other user gets the (view-expanded)
capabilities of their role, or the empty set if the role is
missing/unreadable (fail closed)."""
if user.role == ROLE_ADMIN:
return set(ALL_CAPABILITIES)
if user.role == ROLE_ANONYMOUS:
return {CAP_CHAT}
if role is None:
return set()
return expand_capabilities(role.capabilities)
Expand All @@ -523,13 +533,40 @@ def resolve_scope(client, user: User, prefix: str = "") -> frozenset[str] | None
Explorer API (graph_api) and the remote MCP guard (mcp_http)."""
if user.role == ROLE_ADMIN:
return None
if user.role == ROLE_ANONYMOUS:
return public_scope(client, prefix=prefix)
try:
role = get_role(client, user.role, prefix=prefix)
except Exception:
role = None
return frozenset(role.entry_keys) if role else frozenset()


def public_scope(client, prefix: str = "") -> frozenset[str]:
"""Entry keys an anonymous caller may query: the ENABLED entries whose
visibility is `public`, read live so a flip takes effect on the next
turn. Enforced per entry -- a node's own stored visibility is never
consulted. Fails closed to the empty scope."""
from tpk import corpus # local: corpus imports db, auth must stay light
from tpk.config import entry_key

try:
return frozenset(entry_key(e) for e in corpus.list_entries(client, prefix=prefix)
if e.enabled and e.visibility == "public")
except Exception:
return frozenset()


def anonymous_access_enabled() -> bool:
from tpk.config import as_bool, setting

return as_bool(setting("TPK_ANONYMOUS_ACCESS", "server", "anonymous_access", False))


# Never stored: username/role are reserved sentinels (see ROLE_ANONYMOUS).
ANONYMOUS = User(ANONYMOUS_USERNAME, "", ROLE_ANONYMOUS)


# Precomputed at import time so an unknown-username login still pays the
# same argon2 cost as a known-user/wrong-password login -- otherwise the
# `user is None` short-circuit is a timing oracle for username enumeration
Expand All @@ -551,10 +588,21 @@ def _client(self):

return db.get_client(Settings.from_env())

@staticmethod
def _anonymous_if_allowed(authorization: str | None) -> User | None:
"""The anonymous principal for a request that carries NO credential at
all -- never for a wrong one: a bad token must not silently degrade to
public access."""
if authorization is None and anonymous_access_enabled():
return ANONYMOUS
return None

def _resolve(self, authorization: str | None) -> User:
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(401, "missing bearer token")
token = authorization.removeprefix("Bearer ")
if not token:
raise HTTPException(401, "missing bearer token")
try:
client = self._client()
username = get_session(client, token, prefix=self.prefix)
Expand Down Expand Up @@ -587,8 +635,8 @@ def effective_caps(self, user: User) -> set[str]:
"""Resolve a user's effective capabilities, reading their role from
the store for non-admins. Fails closed (empty set) if the role is
unreadable."""
if user.role == ROLE_ADMIN:
return set(ALL_CAPABILITIES)
if user.role in (ROLE_ADMIN, ROLE_ANONYMOUS):
return effective_capabilities(user, None)
try:
role = get_role(self._client(), user.role, prefix=self.prefix)
except Exception:
Expand All @@ -599,6 +647,10 @@ def require_cap(self, capability: str):
"""Build a FastAPI dependency that admits a user only if they hold
`capability`. Usage: `Depends(auth.require_cap(auth.CAP_CHAT))`."""
def dependency(authorization: str | None = Header(None)) -> User:
if capability == CAP_CHAT:
anon = self._anonymous_if_allowed(authorization)
if anon is not None:
return anon
user = self.require_user(authorization)
if capability not in self.effective_caps(user):
raise HTTPException(403, f"missing capability: {capability}")
Expand All @@ -625,6 +677,8 @@ def login(body: LoginRequest):
try:
client = auth_layer._client()
user = get_user(client, body.username, prefix=prefix)
if body.username == ANONYMOUS_USERNAME:
user = None # reserved: the anonymous principal is never a login
except Exception:
raise HTTPException(503, "auth store unavailable")
# Always run an argon2 verify, even for an unknown username, so the
Expand Down Expand Up @@ -653,10 +707,14 @@ def logout(authorization: str | None = Header(None),
raise HTTPException(503, "auth store unavailable")

@router.get("/me")
def me(user: User = Depends(auth_layer.require_user_any)):
return {"username": user.username, "role": user.role,
"must_change_password": user.must_change_password,
"capabilities": sorted(auth_layer.effective_caps(user))}
def me(authorization: str | None = Header(None)):
user = auth_layer._anonymous_if_allowed(authorization) or auth_layer.require_user_any(authorization)
out = {"username": user.username, "role": user.role,
"must_change_password": user.must_change_password,
"capabilities": sorted(auth_layer.effective_caps(user))}
if user.role == ROLE_ANONYMOUS:
out["anonymous"] = True
return out

@router.post("/change-password")
def change_password(body: ChangePasswordRequest,
Expand Down
7 changes: 7 additions & 0 deletions src/tpk/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,13 @@ def database() -> str:
return name


def anonymous_daily_token_limit() -> int:
"""One GLOBAL daily token budget shared by all anonymous chat (#94):
env TPK_ANONYMOUS_DAILY_TOKEN_LIMIT > [server].anonymous_daily_token_limit
> 100000. 0 disables anonymous chat even when anonymous access is on."""
return int(setting("TPK_ANONYMOUS_DAILY_TOKEN_LIMIT", "server", "anonymous_daily_token_limit", 100_000, cast=int))


def daily_token_limit() -> int:
"""Global fallback daily per-user token budget for non-admin chat (#62):
env TPK_DAILY_TOKEN_LIMIT > [server].daily_token_limit > 500000. Applies to
Expand Down
9 changes: 6 additions & 3 deletions src/tpk/graphify_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@
`rationale`, `concept`. Only `code` is source-derived; the other five are
always non-code entities (extracted from docs/papers/images, or
concept-like nodes such as "ideas, principles, mechanisms, design
patterns") and are treated as doc-kind (`DOC_KINDS`) regardless of the
repo's default visibility. `--code-only` runs only ever emit `code` (for
patterns") and are treated as doc-kind (`DOC_KINDS`). Every node, doc or
code, carries its entry's visibility (#94). `--code-only` runs only ever emit `code` (for
files/functions) in practice, but the parser honors the full six-value
enum so a future non-`--code-only` run parses correctly too.
- `confidence` is one of `EXTRACTED` (explicit in source: import, call,
Expand Down Expand Up @@ -376,7 +376,10 @@ def parse_graph_json(
line = _parse_line(rn)
stable = node_id(repo, kind, qualified)
id_map[raw_id] = stable
visibility = "public" if kind in DOC_KINDS else default_visibility
# Every node carries its ENTRY's visibility. (Doc-kind nodes used to
# be stamped "public" regardless; with anonymous access, #94, the
# entry is the unit of access control, so the label must agree.)
visibility = default_visibility
nodes.append(
Node(
id=stable,
Expand Down
Loading
Loading