154 lines
5.1 KiB
Python
154 lines
5.1 KiB
Python
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
import aiosqlite
|
|
from pydantic import BaseModel
|
|
|
|
from duck_core.tasks.store import utc_now
|
|
|
|
|
|
class Approval(BaseModel):
|
|
id: int | None = None
|
|
approval_id: str
|
|
task_id: str
|
|
action_hash: str
|
|
normalized_action: dict[str, Any]
|
|
status: str
|
|
decision: str | None = None
|
|
created_at: str
|
|
updated_at: str
|
|
|
|
|
|
def normalize_action(action: dict[str, Any]) -> str:
|
|
return json.dumps(action, sort_keys=True, separators=(",", ":"))
|
|
|
|
|
|
def action_hash(action: dict[str, Any]) -> str:
|
|
return hashlib.sha256(normalize_action(action).encode()).hexdigest()
|
|
|
|
|
|
class ApprovalService:
|
|
def __init__(self, db_path: str):
|
|
self.db_path = Path(db_path)
|
|
|
|
async def init(self) -> None:
|
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
async with aiosqlite.connect(self.db_path) as db:
|
|
await db.execute(
|
|
"""
|
|
create table if not exists approvals (
|
|
id integer primary key autoincrement,
|
|
approval_id text not null unique,
|
|
task_id text not null,
|
|
action_hash text not null,
|
|
normalized_action_json text not null,
|
|
status text not null,
|
|
decision text,
|
|
created_at text not null,
|
|
updated_at text not null
|
|
)
|
|
"""
|
|
)
|
|
await db.commit()
|
|
|
|
async def create_pending(self, task_id: str, action: dict[str, Any]) -> Approval:
|
|
await self.init()
|
|
now = utc_now()
|
|
approval_id = f"approval_{uuid4().hex[:12]}"
|
|
normalized = normalize_action(action)
|
|
digest = action_hash(action)
|
|
async with aiosqlite.connect(self.db_path) as db:
|
|
cursor = await db.execute(
|
|
"""
|
|
insert into approvals(
|
|
approval_id, task_id, action_hash, normalized_action_json,
|
|
status, created_at, updated_at
|
|
) values (?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(approval_id, task_id, digest, normalized, "pending", now, now),
|
|
)
|
|
await db.commit()
|
|
row_id = cursor.lastrowid
|
|
return Approval(
|
|
id=row_id,
|
|
approval_id=approval_id,
|
|
task_id=task_id,
|
|
action_hash=digest,
|
|
normalized_action=action,
|
|
status="pending",
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
|
|
async def pending(self) -> list[Approval]:
|
|
await self.init()
|
|
async with aiosqlite.connect(self.db_path) as db:
|
|
db.row_factory = aiosqlite.Row
|
|
cursor = await db.execute(
|
|
"select * from approvals where status = 'pending' order by created_at"
|
|
)
|
|
rows = await cursor.fetchall()
|
|
return [self._row_to_approval(row) for row in rows]
|
|
|
|
async def get(self, approval_id: str) -> Approval | None:
|
|
await self.init()
|
|
async with aiosqlite.connect(self.db_path) as db:
|
|
db.row_factory = aiosqlite.Row
|
|
cursor = await db.execute(
|
|
"select * from approvals where approval_id = ?", (approval_id,)
|
|
)
|
|
row = await cursor.fetchone()
|
|
return self._row_to_approval(row) if row else None
|
|
|
|
async def allow_once(self, approval_id: str) -> None:
|
|
await self._decide(approval_id, "resolved", "allow_once")
|
|
|
|
async def allow_forever(self, approval_id: str) -> None:
|
|
await self._decide(approval_id, "allowed_forever", "allow_forever")
|
|
|
|
async def deny(self, approval_id: str) -> None:
|
|
await self._decide(approval_id, "resolved", "deny")
|
|
|
|
async def is_allowed_forever(self, action: dict[str, Any]) -> bool:
|
|
await self.init()
|
|
digest = action_hash(action)
|
|
async with aiosqlite.connect(self.db_path) as db:
|
|
cursor = await db.execute(
|
|
"""
|
|
select 1 from approvals
|
|
where action_hash = ? and status = 'allowed_forever'
|
|
limit 1
|
|
""",
|
|
(digest,),
|
|
)
|
|
row = await cursor.fetchone()
|
|
return row is not None
|
|
|
|
async def _decide(self, approval_id: str, status: str, decision: str) -> None:
|
|
await self.init()
|
|
async with aiosqlite.connect(self.db_path) as db:
|
|
await db.execute(
|
|
"""
|
|
update approvals set status = ?, decision = ?, updated_at = ?
|
|
where approval_id = ?
|
|
""",
|
|
(status, decision, utc_now(), approval_id),
|
|
)
|
|
await db.commit()
|
|
|
|
def _row_to_approval(self, row: aiosqlite.Row) -> Approval:
|
|
return Approval(
|
|
id=row["id"],
|
|
approval_id=row["approval_id"],
|
|
task_id=row["task_id"],
|
|
action_hash=row["action_hash"],
|
|
normalized_action=json.loads(row["normalized_action_json"]),
|
|
status=row["status"],
|
|
decision=row["decision"],
|
|
created_at=row["created_at"],
|
|
updated_at=row["updated_at"],
|
|
)
|