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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ OPENAI_API_KEY=
# TPK_DB_WAIT_SECONDS=60 # [db].wait_seconds serve: DB connect retry budget
# TPK_SESSION_TTL=86400 # [server].session_ttl login session lifetime (s)
# TPK_CHAT_AUDIT=1 # [server].chat_audit false/0 disables chat auditing
# TPK_DAILY_TOKEN_LIMIT=500000 # [server].daily_token_limit global fallback non-admin per-user/day cap; 0 = unlimited
# TPK_CHECKOUT_DIR=~/.tpk/checkouts # [server].checkout_dir github checkout cache
# TPK_EXTRACTION_BACKEND= # [llm].backend auto | claude | openai
# TPK_EXTRACTION_MODEL= # [llm].model semantic-extraction model
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export the matching env var — whichever suits your deployment. Secrets are
| `TPK_CHECKOUT_DIR` | `[server].checkout_dir` | `~/.tpk/checkouts` | GitHub checkout cache |
| `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_EXTRACTION_BACKEND` | `[llm].backend` | `auto` | Semantic-extraction backend (`auto`\|`claude`\|`openai`) |
| `TPK_EXTRACTION_MODEL` | `[llm].model` | backend default | Semantic-extraction model |

Expand Down
1 change: 1 addition & 0 deletions deploy/docker/repos.container.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ token_budget = 16000
# checkout_dir = "/opt/tpk/.checkouts" # env: TPK_CHECKOUT_DIR
# session_ttl = 86400 # env: TPK_SESSION_TTL
# 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)

[repos.proton-enterprise]
github = "timeplus-io/proton-enterprise"
Expand Down
17 changes: 17 additions & 0 deletions deploy/k8s/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,22 @@ Or, to remap the port, a headless Service + an `EndpointSlice` pointing at the
DB's IP on its real port, exposed as `port: 8123`. The DB user in `TIMEPLUS_USER`
must already exist on your timeplusd — `app-only.yaml` does not provision users.

**The `tpk` database.** All tpk streams live under a dedicated database (`tpk`
by default, `TIMEPLUS_DATABASE`), which the app creates on startup (`CREATE
DATABASE IF NOT EXISTS`). Against a shared, externally-managed Timeplus this is
the one extra grant to check: the `TIMEPLUS_USER` needs **CREATE DATABASE** (the
first time) plus read/write on that database. If that user isn't allowed to
create databases, pre-create it and grant access, then keep `TIMEPLUS_DATABASE`
pointed at it:

```sql
CREATE DATABASE IF NOT EXISTS tpk;
-- grant your tpk user read/write on tpk (per your Timeplus access model)
```

This only applies to app-only: `enterprise.yaml` and `allinone.yaml` provision a
`tpk` user with full privileges, so database creation just works there.

## 3. Build the knowledge graph (ingest)

The corpus is defined in the `repos.toml` baked into the image. Run ingest once
Expand Down Expand Up @@ -169,6 +185,7 @@ manifests, others stubbed as commented-out examples):
| `TPK_AGENT_PROVIDER` / `TPK_AGENT_MODEL` | chat-agent backend + model |
| `TPK_EXTRACTION_BACKEND` | `tpk ingest` semantic backend (`openai`\|`claude`\|`auto`) |
| `TPK_DB_BACKEND` | `timeplusd` (Enterprise, mutable streams) or `proton` |
| `TIMEPLUS_DATABASE` | database all tpk streams live under (default `tpk`; app-only: user needs CREATE DATABASE) |
| `TPK_DB_WAIT_SECONDS` | how long the app waits for the DB on boot |

### Custom corpus (`repos.toml`) via ConfigMap
Expand Down
13 changes: 12 additions & 1 deletion deploy/k8s/app-only.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
# App-only deployment: the tpk `app` (chat agent + web UI + ingest + MCP)
# ALONE, connecting to a Timeplus Enterprise (timeplusd) that is ALREADY
# running — deployed and managed separately (its own Helm chart / operator /
# existing cluster). Nothing here provisions or runs a database.
# existing cluster). This does not run a database *server*; but on startup the
# app DOES create its own database (`tpk` by default) on that server and puts
# all its streams there. So the DB user you point it at needs CREATE DATABASE
# (first run only) plus read/write on that database. If the user is restricted,
# pre-create the database and grant it, then set TIMEPLUS_DATABASE (see below).
#
# Use this when you already operate Timeplus Enterprise and just want to add the
# knowledge agent against it. If you want tpk to bring up its own timeplusd too,
Expand Down Expand Up @@ -84,6 +88,13 @@ spec:
# Enterprise timeplusd -> `timeplusd` backend (mutable streams).
- name: TPK_DB_BACKEND
value: "timeplusd"
# All tpk streams live under this database, which the app creates on
# startup (CREATE DATABASE IF NOT EXISTS) — so the DB user needs
# CREATE DATABASE the first time, plus read/write on it. If the user
# can't create databases, pre-create this one and grant access, or
# point at an existing database you control.
- name: TIMEPLUS_DATABASE
value: "tpk"
# The DB is already up; a short connect-retry covers transient blips.
- name: TPK_DB_WAIT_SECONDS
value: "120"
Expand Down
3 changes: 3 additions & 0 deletions docker-compose.allinone.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ services:
TPK_AGENT_MODEL: ${TPK_AGENT_MODEL:-}
TPK_AGENT_REASONING_EFFORT: ${TPK_AGENT_REASONING_EFFORT:-}
TPK_CHAT_AUDIT: ${TPK_CHAT_AUDIT:-}
# Global fallback daily token budget for non-admin chat (0 = unlimited);
# a role's own daily_token_limit (Users -> Roles) overrides it.
TPK_DAILY_TOKEN_LIMIT: ${TPK_DAILY_TOKEN_LIMIT:-}
# Semantic-extraction (graphify) backend. `auto` is ambiguous when both
# API keys are set (picks claude), so set this to openai|claude to choose.
# The model comes from OPENAI_MODEL / ANTHROPIC_MODEL (graphify reads them).
Expand Down
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ services:
TPK_AGENT_MODEL: ${TPK_AGENT_MODEL:-}
TPK_AGENT_REASONING_EFFORT: ${TPK_AGENT_REASONING_EFFORT:-}
TPK_CHAT_AUDIT: ${TPK_CHAT_AUDIT:-}
# Global fallback daily token budget for non-admin chat (0 = unlimited);
# a role's own daily_token_limit (Users -> Roles) overrides it.
TPK_DAILY_TOKEN_LIMIT: ${TPK_DAILY_TOKEN_LIMIT:-}
# Semantic-extraction (graphify) backend: openai|claude|auto. `auto` is
# ambiguous when both API keys are set; model comes from OPENAI_MODEL /
# ANTHROPIC_MODEL.
Expand Down
1 change: 1 addition & 0 deletions repos.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ token_budget = 16000
# checkout_dir = "~/.tpk/checkouts" # env: TPK_CHECKOUT_DIR (github checkout cache)
# session_ttl = 86400 # env: TPK_SESSION_TTL (login session lifetime, seconds)
# 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)

[repos.proton-enterprise]
github = "timeplus-io/proton-enterprise"
Expand Down
44 changes: 39 additions & 5 deletions src/tpk/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,16 @@ class AddUser(BaseModel):
password: str
role: str
must_change_password: bool = True
# Per-user daily token budget override (0 = inherit role/global, #62).
daily_token_limit: int = 0


class UpdateUser(BaseModel):
username: str
role: str | None = None
disabled: bool | None = None
password: str | None = None # admin reset; sets must_change_password
daily_token_limit: int | None = None # omitted -> unchanged


class DeleteUser(BaseModel):
Expand All @@ -84,6 +87,9 @@ class UpsertRole(BaseModel):
# Omitted -> the chat-only default (friendly for API-only callers); an
# explicit [] is honored as a zero-capability role.
capabilities: list[str] | None = None
# Daily per-user token budget for this role's members (0 = inherit the
# global fallback, #62).
daily_token_limit: int = 0


class DeleteRole(BaseModel):
Expand Down Expand Up @@ -200,7 +206,8 @@ def _client():
def _user_json(u):
return {"username": u.username, "role": u.role,
"must_change_password": u.must_change_password,
"disabled": u.disabled}
"disabled": u.disabled,
"daily_token_limit": u.daily_token_limit}

def _check_role_exists(client, name: str):
if name != auth_mod.ROLE_ADMIN and auth_mod.get_role(client, name, prefix=prefix) is None:
Expand Down Expand Up @@ -234,6 +241,17 @@ def _guard_admin_target(actor, target: "auth_mod.User"):
if actor.role != auth_mod.ROLE_ADMIN and target.role == auth_mod.ROLE_ADMIN:
raise HTTPException(403, "cannot manage admin users")

def _guard_token_limit(actor, requested, current):
"""A daily token budget is a cost-governance lever: only admins may
change one (#62/#24 bounded delegation). A non-admin users:manage
delegate may still manage users/roles as long as it leaves the budget
unchanged, but may not set or raise it — else a limited manager could
hand out unlimited/huge LLM spend through these endpoints."""
if requested is None:
return
if actor.role != auth_mod.ROLE_ADMIN and int(requested) != int(current or 0):
raise HTTPException(403, "only an admin can change the daily token budget")

def _guard_grant(client, actor, caps, entry_keys):
"""Reject a grant (role capabilities/entry_keys) exceeding the actor's
own. No-op for admins."""
Expand Down Expand Up @@ -339,11 +357,15 @@ def api_add_user(body: AddUser, actor: User = Depends(auth.require_cap(auth_mod.
client = _client()
if auth_mod.get_user(client, body.username, prefix=prefix) is not None:
raise HTTPException(409, "user already exists")
if body.daily_token_limit < 0:
raise HTTPException(400, "daily_token_limit must be >= 0 (0 = inherit)")
_guard_token_limit(actor, body.daily_token_limit, 0)
_check_role_exists(client, body.role)
_guard_role_assignment(client, actor, body.role)
auth_mod.upsert_user(client, auth_mod.User(
body.username, auth_mod.hash_password(body.password), body.role,
must_change_password=body.must_change_password), prefix=prefix)
must_change_password=body.must_change_password,
daily_token_limit=body.daily_token_limit), prefix=prefix)
return {"ok": True}

@router.post("/users/update")
Expand All @@ -359,14 +381,20 @@ def api_update_user(body: UpdateUser, actor: User = Depends(auth.require_cap(aut
_check_role_exists(client, role)
_guard_role_assignment(client, actor, role)
disabled = body.disabled if body.disabled is not None else u.disabled
if body.daily_token_limit is not None and body.daily_token_limit < 0:
raise HTTPException(400, "daily_token_limit must be >= 0 (0 = inherit)")
_guard_token_limit(actor, body.daily_token_limit, u.daily_token_limit)
token_limit = body.daily_token_limit if body.daily_token_limit is not None \
else u.daily_token_limit
password_hash, must_change = u.password_hash, u.must_change_password
if body.password is not None:
err = auth_mod.validate_new_password(body.password)
if err:
raise HTTPException(400, err)
password_hash, must_change = auth_mod.hash_password(body.password), True
auth_mod.upsert_user(client, auth_mod.User(
u.username, password_hash, role, must_change, disabled), prefix=prefix)
u.username, password_hash, role, must_change, disabled,
daily_token_limit=token_limit), prefix=prefix)
if disabled or body.password is not None:
auth_mod.delete_user_sessions(client, u.username, prefix=prefix)
return {"ok": True}
Expand All @@ -388,7 +416,8 @@ def api_delete_user(body: DeleteUser, actor: User = Depends(auth.require_cap(aut
@router.get("/roles")
def api_list_roles(actor: User = Depends(auth.require_cap(auth_mod.CAP_USERS_VIEW))):
return [{"name": r.name, "entry_keys": r.entry_keys,
"capabilities": r.capabilities, "description": r.description}
"capabilities": r.capabilities, "description": r.description,
"daily_token_limit": r.daily_token_limit}
for r in auth_mod.list_roles(_client(), prefix=prefix)]

@router.post("/roles")
Expand All @@ -404,17 +433,22 @@ def api_upsert_role(body: UpsertRole, actor: User = Depends(auth.require_cap(aut
unknown = [c for c in caps if c not in auth_mod.ALL_CAPABILITIES]
if unknown:
raise HTTPException(400, f"unknown capabilities: {', '.join(unknown)}")
if body.daily_token_limit < 0:
raise HTTPException(400, "daily_token_limit must be >= 0 (0 = inherit)")
client = _client()
# Guard both the new values AND (when overwriting) the role's current
# privileges -- else a non-admin manager could neuter a role more
# privileged than their own grant, stripping its members (mirrors the
# delete-path guard).
existing = auth_mod.get_role(client, body.name, prefix=prefix)
_guard_token_limit(actor, body.daily_token_limit,
existing.daily_token_limit if existing else 0)
if existing is not None:
_guard_grant(client, actor, existing.capabilities, existing.entry_keys)
_guard_grant(client, actor, caps, body.entry_keys)
auth_mod.upsert_role(client, auth_mod.Role(
body.name, body.entry_keys, body.description, caps), prefix=prefix)
body.name, body.entry_keys, body.description, caps,
daily_token_limit=body.daily_token_limit), prefix=prefix)
return {"ok": True}

@router.post("/roles/delete")
Expand Down
46 changes: 29 additions & 17 deletions src/tpk/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,9 @@ def expand_capabilities(caps) -> set[str]:
_hasher = PasswordHasher() # argon2id defaults

_USER_COLUMNS = ["username", "password_hash", "role", "must_change_password",
"disabled", "created_at", "updated_at"]
_ROLE_COLUMNS = ["name", "entry_keys", "capabilities", "description", "updated_at"]
"disabled", "daily_token_limit", "created_at", "updated_at"]
_ROLE_COLUMNS = ["name", "entry_keys", "capabilities", "description",
"daily_token_limit", "updated_at"]
_SESSION_COLUMNS = ["token_hash", "username", "expires_at", "created_at"]


Expand All @@ -77,6 +78,9 @@ class User:
role: str
must_change_password: bool = False
disabled: bool = False
# Per-user daily token budget override (0 = inherit the role/global limit).
# Takes precedence over the role's daily_token_limit when > 0. See #62.
daily_token_limit: int = 0


@dataclass
Expand All @@ -85,6 +89,10 @@ class Role:
entry_keys: list[str] = field(default_factory=list)
description: str = ""
capabilities: list[str] = field(default_factory=lambda: list(DEFAULT_CAPABILITIES))
# Daily per-user token budget for members of this role (0 = inherit the
# global fallback, config.daily_token_limit; NOT unlimited unless the global
# itself is 0). A per-user override still wins over this. See #62.
daily_token_limit: int = 0


def _parse_capabilities(raw: str) -> list[str]:
Expand Down Expand Up @@ -137,29 +145,31 @@ def upsert_user(client, user: User, prefix: str = "") -> None:
client.insert(
db.qualified("kg_users", prefix),
[[user.username, user.password_hash, user.role,
user.must_change_password, user.disabled, _now(), _now()]],
user.must_change_password, user.disabled,
max(int(user.daily_token_limit), 0), _now(), _now()]],
column_names=_USER_COLUMNS,
)


def get_user(client, username: str, prefix: str = "") -> User | None:
rows = client.query(
f"SELECT username, password_hash, role, must_change_password, disabled"
f"SELECT username, password_hash, role, must_change_password, disabled, daily_token_limit"
f" FROM {db.latest(db.qualified('kg_users', prefix))} WHERE username = %(u)s",
parameters={"u": username},
).result_rows
if not rows:
return None
u, h, r, mc, dis = rows[0]
return User(u, h, r, bool(mc), bool(dis))
u, h, r, mc, dis, lim = rows[0]
return User(u, h, r, bool(mc), bool(dis), daily_token_limit=int(lim or 0))


def list_users(client, prefix: str = "") -> list[User]:
rows = client.query(
f"SELECT username, password_hash, role, must_change_password, disabled"
f"SELECT username, password_hash, role, must_change_password, disabled, daily_token_limit"
f" FROM {db.latest(db.qualified('kg_users', prefix))} ORDER BY username"
).result_rows
return [User(u, h, r, bool(mc), bool(dis)) for u, h, r, mc, dis in rows]
return [User(u, h, r, bool(mc), bool(dis), daily_token_limit=int(lim or 0))
for u, h, r, mc, dis, lim in rows]


def delete_user(client, username: str, prefix: str = "") -> None:
Expand Down Expand Up @@ -194,30 +204,32 @@ def upsert_role(client, role: Role, prefix: str = "") -> None:
client.insert(
db.qualified("kg_roles", prefix),
[[role.name, json.dumps(role.entry_keys), json.dumps(role.capabilities),
role.description, _now()]],
role.description, max(int(role.daily_token_limit), 0), _now()]],
column_names=_ROLE_COLUMNS,
)


def get_role(client, name: str, prefix: str = "") -> Role | None:
rows = client.query(
f"SELECT name, entry_keys, capabilities, description FROM {db.latest(db.qualified('kg_roles', prefix))}"
f" WHERE name = %(n)s",
f"SELECT name, entry_keys, capabilities, description, daily_token_limit"
f" FROM {db.latest(db.qualified('kg_roles', prefix))} WHERE name = %(n)s",
parameters={"n": name},
).result_rows
if not rows:
return None
n, keys, caps, desc = rows[0]
return Role(n, json.loads(keys) if keys else [], desc, _parse_capabilities(caps))
n, keys, caps, desc, limit = rows[0]
return Role(n, json.loads(keys) if keys else [], desc, _parse_capabilities(caps),
daily_token_limit=int(limit or 0))


def list_roles(client, prefix: str = "") -> list[Role]:
rows = client.query(
f"SELECT name, entry_keys, capabilities, description FROM {db.latest(db.qualified('kg_roles', prefix))}"
f" ORDER BY name"
f"SELECT name, entry_keys, capabilities, description, daily_token_limit"
f" FROM {db.latest(db.qualified('kg_roles', prefix))} ORDER BY name"
).result_rows
return [Role(n, json.loads(k) if k else [], d, _parse_capabilities(c))
for n, k, c, d in rows]
return [Role(n, json.loads(k) if k else [], d, _parse_capabilities(c),
daily_token_limit=int(lim or 0))
for n, k, c, d, lim in rows]


def delete_role(client, name: str, prefix: str = "") -> None:
Expand Down
9 changes: 9 additions & 0 deletions src/tpk/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,15 @@ def database() -> str:
return name


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
users whose role/user override does not set its own `daily_token_limit`.
Set to 0 for unlimited-by-default."""
val = setting("TPK_DAILY_TOKEN_LIMIT", "server", "daily_token_limit", 500000, cast=int)
return max(int(val), 0)


@dataclass(frozen=True)
class RepoConfig:
name: str
Expand Down
Loading
Loading