fix(auth): scope path-like name args by command
Build and Push Docker Container / build-and-push (push) Successful in 4m41s
Build and Push Docker Container / build-and-push (push) Successful in 4m41s
- Stop treating property names as vault paths during policy checks. - Keep rename name targets protected by path restrictions. - Add regression coverage for property:set name=from. - Add regression coverage for blocked rename targets. - Bump package version to 1.0.4 for the Docker image tag.
This commit is contained in:
+9
-2
@@ -29,7 +29,10 @@ class CommandTarget:
|
|||||||
vault:str|None
|
vault:str|None
|
||||||
paths:tuple[str, ...]
|
paths:tuple[str, ...]
|
||||||
|
|
||||||
PATH_KEYS = {"path", "file", "folder", "name", "to"}
|
DEFAULT_PATH_KEYS = {"path", "file", "folder", "to"}
|
||||||
|
PATH_KEYS_BY_COMMAND = {
|
||||||
|
"rename": DEFAULT_PATH_KEYS | {"name"},
|
||||||
|
}
|
||||||
PATH_REQUIRED_WHEN_RESTRICTED = {
|
PATH_REQUIRED_WHEN_RESTRICTED = {
|
||||||
"append", "prepend", "read", "create", "delete", "rename", "move", "file", "folder", "open", "diff",
|
"append", "prepend", "read", "create", "delete", "rename", "move", "file", "folder", "open", "diff",
|
||||||
"search", "search:context", "files", "folders",
|
"search", "search:context", "files", "folders",
|
||||||
@@ -100,6 +103,9 @@ def parse_kv_arg(arg:str) -> tuple[str, str]|None:
|
|||||||
return None
|
return None
|
||||||
return key, value
|
return key, value
|
||||||
|
|
||||||
|
def path_keys_for_command(command:str|None) -> set[str]:
|
||||||
|
return PATH_KEYS_BY_COMMAND.get(command or "", DEFAULT_PATH_KEYS)
|
||||||
|
|
||||||
def extract_command_target(args:list[str]) -> CommandTarget:
|
def extract_command_target(args:list[str]) -> CommandTarget:
|
||||||
vault:str|None = None
|
vault:str|None = None
|
||||||
command:str|None = None
|
command:str|None = None
|
||||||
@@ -113,12 +119,13 @@ def extract_command_target(args:list[str]) -> CommandTarget:
|
|||||||
command = arg
|
command = arg
|
||||||
break
|
break
|
||||||
|
|
||||||
|
path_keys = path_keys_for_command(command)
|
||||||
for arg in args:
|
for arg in args:
|
||||||
parsed = parse_kv_arg(arg)
|
parsed = parse_kv_arg(arg)
|
||||||
if not parsed:
|
if not parsed:
|
||||||
continue
|
continue
|
||||||
key, value = parsed
|
key, value = parsed
|
||||||
if key in PATH_KEYS and value:
|
if key in path_keys and value:
|
||||||
paths.append(value)
|
paths.append(value)
|
||||||
|
|
||||||
return CommandTarget(command=command, vault=vault, paths=tuple(paths))
|
return CommandTarget(command=command, vault=vault, paths=tuple(paths))
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "obsidian-api"
|
name = "obsidian-api"
|
||||||
version = "1.0.3"
|
version = "1.0.4"
|
||||||
description = "HTTP API wrapper for running Obsidian CLI commands from n8n, SDKs, or agent harnesses."
|
description = "HTTP API wrapper for running Obsidian CLI commands from n8n, SDKs, or agent harnesses."
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -157,6 +157,30 @@ def test_token_policy_restricts_path(monkeypatch):
|
|||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert response.json()["detail"].startswith("token is not allowed to access path: Private/Secret.md")
|
assert response.json()["detail"].startswith("token is not allowed to access path: Private/Secret.md")
|
||||||
|
|
||||||
|
def test_property_set_name_is_not_treated_as_path(monkeypatch):
|
||||||
|
token = use_jwt_auth(monkeypatch, {"sub": "journal", "commands": ["property:set"], "vaults": ["*"], "paths": ["00. Journal/"]})
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/commands",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"args": ["property:set", "path=00. Journal/Note.md", "name=from", "value=Daniel", "type=text"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["command"] == ["python", "property:set", "path=00. Journal/Note.md", "name=from", "value=Daniel", "type=text"]
|
||||||
|
|
||||||
|
def test_rename_name_is_treated_as_path(monkeypatch):
|
||||||
|
token = use_jwt_auth(monkeypatch, {"sub": "journal", "commands": ["rename"], "vaults": ["*"], "paths": ["00. Journal/"]})
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/commands",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"args": ["rename", "path=00. Journal/Old.md", "name=Private/New.md"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json()["detail"].startswith("token is not allowed to access path: Private/New.md")
|
||||||
|
|
||||||
def test_token_policy_allows_allowed_path(monkeypatch):
|
def test_token_policy_allows_allowed_path(monkeypatch):
|
||||||
token = use_jwt_auth(monkeypatch, {"sub": "inbox", "commands": ["read"], "vaults": ["*"], "paths": ["Inbox/"]})
|
token = use_jwt_auth(monkeypatch, {"sub": "inbox", "commands": ["read"], "vaults": ["*"], "paths": ["Inbox/"]})
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "obsidian-api"
|
name = "obsidian-api"
|
||||||
version = "1.0.3"
|
version = "1.0.4"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "starlette" },
|
{ name = "starlette" },
|
||||||
|
|||||||
Reference in New Issue
Block a user