c34dab2cca
Build and Push Docker Container / build-and-push (push) Successful in 4m28s
- Create missing note files before running property:set. - Default date-only datetime values to midnight. - Keep property names out of path authorization checks. - Split policy, vault, daily note, and command target helpers. - Split n8n node args, description, output, and API response helpers. - Split API tests by auth, command, daily note, and property behavior. - Bump API and n8n node versions.
93 lines
3.3 KiB
Python
93 lines
3.3 KiB
Python
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}"
|
|
)
|