from dataclasses import dataclass from pathlib import Path from typing import Any from app.command_target import CommandTarget, extract_command_target, parse_kv_arg from app.config import DEFAULT_VAULT_NAME from app.daily_notes import daily_note_path_for_vault from app.path_policy import is_path_allowed, normalize_policy_path from app.vaults import vault_for_path, vault_root_for_target @dataclass(frozen=True) class TokenPolicy: token:str = "" name:str = "jwt" vaults:tuple[str, ...] = ("*",) paths:tuple[str, ...] = ("*",) commands:tuple[str, ...] = ("*",) def allows_all_vaults(self) -> bool: return "*" in self.vaults def allows_all_paths(self) -> bool: return "*" in self.paths def allows_all_commands(self) -> bool: return "*" in self.commands PATH_REQUIRED_WHEN_RESTRICTED = { "append", "prepend", "read", "create", "delete", "rename", "move", "file", "folder", "open", "diff", "search", "search:context", "files", "folders", "property:set", "property:remove", "property:read", "properties", "aliases", "backlinks", "links", "outlinks", "tags", "tasks", "task", } PATHLESS_ALLOWED_WHEN_RESTRICTED = {"help", "version", "vault", "vaults"} DAILY_NOTE_COMMANDS = {"daily:path", "daily:read", "daily:append", "daily:prepend"} def tuple_claim(value:Any, default:tuple[str, ...]) -> tuple[str, ...]: if value is None: return default if isinstance(value, str): return (value,) if isinstance(value, list) and all(isinstance(item, str) for item in value): return tuple(value) raise ValueError("JWT claims vaults, paths and commands must be strings or string arrays") def authorize_command(policy:TokenPolicy|None, args:list[str], cwd:Path) -> None: if policy is None: return target = extract_command_target(args) command = target.command or "" if not policy.allows_all_commands() and command not in policy.commands: allowed = ", ".join(sorted(policy.commands)) or "(none)" raise PermissionError( f"token is not allowed to run command: {command or '(empty)'}. allowed commands: {allowed}" ) if not policy.allows_all_vaults(): vault = target.vault or vault_for_path(cwd) or DEFAULT_VAULT_NAME if vault not in policy.vaults: allowed = ", ".join(sorted(policy.vaults)) or "(none)" raise PermissionError( f"token is not allowed to access vault: {vault}. allowed vaults: {allowed}" ) if policy.allows_all_paths(): return paths = list(target.paths) if not paths and command in DAILY_NOTE_COMMANDS: paths.append(daily_note_path_for_vault(vault_root_for_target(target, cwd))) allowed_paths = ", ".join(policy.paths) or "(none)" if not paths: if command in PATHLESS_ALLOWED_WHEN_RESTRICTED: return if command in PATH_REQUIRED_WHEN_RESTRICTED: raise PermissionError( f"token has path restrictions; command '{command}' must include an allowed " f"path/file/folder/name argument. allowed paths: {allowed_paths}" ) raise PermissionError( f"token has path restrictions; command '{command}' is not allowed without an " f"explicit path. allowed paths: {allowed_paths}" ) denied = [path for path in paths if not is_path_allowed(path, policy.paths)] if denied: raise PermissionError( f"token is not allowed to access path: {denied[0]}. allowed paths: {allowed_paths}" )