From c884d21487bdc0383b0a4fc78087a752d103106a Mon Sep 17 00:00:00 2001 From: Hogne <227774406+hognek@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:46:08 +0200 Subject: [PATCH 1/6] feat(user_shares): add share routes + consent wiring (POST/GET/DELETE /api/shares) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New tinyagentos/routes/user_shares.py with three authenticated routes: POST /api/shares — share a resource with another user by username GET /api/shares — list shares (direction=out|in) DELETE /api/shares/{id} — revoke a share (owner or admin) - Consent wiring: on share-create, raises a notification and a Decision record for the target user (mirrors agent_auth_requests.py pattern) - Module-level user_can_access() helper for route consumers - Registered UserSharesStore in app.py lifespan and router in routes/__init__.py Depends on PR #1897 (UserSharesStore — feat/user-shares-store branch) --- tinyagentos/app.py | 5 + tinyagentos/routes/__init__.py | 3 + tinyagentos/routes/user_shares.py | 228 ++++++++++++++++++++++++++++++ 3 files changed, 236 insertions(+) create mode 100644 tinyagentos/routes/user_shares.py diff --git a/tinyagentos/app.py b/tinyagentos/app.py index 32cc90d59..b9b6b0274 100644 --- a/tinyagentos/app.py +++ b/tinyagentos/app.py @@ -283,6 +283,8 @@ def create_app(data_dir: Path | None = None, catalog_dir: Path | None = None) -> auth_requests_store = AuthRequestsStore(data_dir / "auth_requests.db") from tinyagentos.agent_grants_store import AgentGrantsStore agent_grants_store = AgentGrantsStore(data_dir / "agent_grants.db") + from tinyagentos.user_shares_store import UserSharesStore + user_shares_store = UserSharesStore(data_dir / "user_shares.db") from tinyagentos.app_grants_store import AppGrantsStore app_grants_store = AppGrantsStore(data_dir / "app_grants.db") from tinyagentos.license_acceptances_store import LicenseAcceptancesStore @@ -503,6 +505,9 @@ async def lifespan(app: FastAPI): await agent_registry_store.init() await auth_requests_store.init() await agent_grants_store.init() + app.state.agent_grants = agent_grants_store + await user_shares_store.init() + app.state.user_shares = user_shares_store await app_grants_store.init() await license_acceptances_store.init() await agent_model_key_store.init() diff --git a/tinyagentos/routes/__init__.py b/tinyagentos/routes/__init__.py index 8a64761c6..ac9f16ef2 100644 --- a/tinyagentos/routes/__init__.py +++ b/tinyagentos/routes/__init__.py @@ -381,6 +381,9 @@ def register_all_routers(app): from tinyagentos.routes.app_permissions import router as app_permissions_router app.include_router(app_permissions_router, dependencies=_csrf) + from tinyagentos.routes.user_shares import router as user_shares_router + app.include_router(user_shares_router) + from tinyagentos.routes.agent_model_api import router as agent_model_api_router app.include_router(agent_model_api_router, dependencies=_csrf) diff --git a/tinyagentos/routes/user_shares.py b/tinyagentos/routes/user_shares.py new file mode 100644 index 000000000..3e745df27 --- /dev/null +++ b/tinyagentos/routes/user_shares.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +"""Routes for user-to-user resource sharing. + +POST /api/shares — share a resource with another user by username +GET /api/shares — list shares (direction=out → owned, direction=in → received) +DELETE /api/shares/{id} — revoke a share (owner or admin) + +The consent loop mirrors the external-agent consent pattern in +``agent_auth_requests.py``: on share-create a notification is raised to the +target user and a Decision record is created so the desktop consent actions +can approve / deny later. +""" + +import logging + +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from pydantic import BaseModel + +from tinyagentos.auth_context import CurrentUser, current_user, require_owner_or_admin + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# --------------------------------------------------------------------------- +# Request bodies +# --------------------------------------------------------------------------- + +class CreateShareRequest(BaseModel): + resource_type: str + resource_id: str + to_username: str + permission: str + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _get_user_shares_store(request: Request): + store = getattr(request.app.state, "user_shares", None) + if store is None: + raise RuntimeError("user_shares store not on app.state") + return store + + +async def user_can_access( + request: Request, resource_type: str, resource_id: str, user_id: str +) -> bool: + """Module-level helper so route consumers can check share access. + + Returns True if *user_id* has at least one active, non-expired share + for (*resource_type*, *resource_id*). Importable from + ``tinyagentos.routes.user_shares``. + """ + store = _get_user_shares_store(request) + return await store.user_can_access(resource_type, resource_id, user_id) + + +async def _find_share_by_id(request: Request, share_id: int) -> dict | None: + """Look up a share by id across active shares and the caller's owned shares. + + Active-shares-first avoids scanning expired shares in the common case + where the share is still alive. + """ + store = _get_user_shares_store(request) + # Active shares first (covers the common case for both owner and admin). + for s in await store.list_active_shares(): + if s["id"] == share_id: + return s + # Fall back to caller's owned shares (includes expired shares the owner + # might still want to delete). + user_id: str = getattr(request.state, "user_id", "") + if user_id: + for s in await store.list_shares(user_id): + if s["id"] == share_id: + return s + return None + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + +@router.post("/api/shares") +async def create_share( + request: Request, + body: CreateShareRequest, + user: CurrentUser = Depends(current_user), +): + """Share a resource with another user by username. + + Resolves *to_username* via the AuthManager → 404 if not found. + Duplicate share (same owner + resource + target + permission) is + idempotent — the store replaces the existing row. + + On create, raises a notification and a Decision record to the target + user so the desktop consent actions can approve / deny. + """ + store = _get_user_shares_store(request) + + # Resolve target user by username. + auth = getattr(request.app.state, "auth", None) + if auth is None: + raise HTTPException(status_code=500, detail="auth manager not available") + + target = auth.find_user(body.to_username) + if target is None: + raise HTTPException(status_code=404, detail=f"user '{body.to_username}' not found") + + target_user_id: str = target["id"] + + # Guard against self-share — creates a confusing UX and an unnecessary + # Decision against yourself. + if target_user_id == user.user_id: + raise HTTPException(status_code=400, detail="cannot share with yourself") + + # Create (or replace) the share. The store's write lock makes this + # idempotent for concurrent same-key writes. + record = await store.add_share( + owner_user_id=user.user_id, + resource_type=body.resource_type, + resource_id=body.resource_id, + shared_with_user_id=target_user_id, + permission=body.permission, + ) + + # ------------------------------------------------------------------ + # Consent wiring — notification (same pattern as agent_auth_requests.py + # lines 204-221). Best effort: a notification failure must not fail the + # created share. + # ------------------------------------------------------------------ + notifs = getattr(request.app.state, "notifications", None) + if notifs is not None: + try: + await notifs.add( + title="Resource shared with you", + message=( + f"{user.user_id} shared {body.resource_type}/{body.resource_id} " + f"with you (permission: {body.permission})" + ), + level="info", + source="user_shares", + data={ + "share_id": record["id"], + "owner_user_id": user.user_id, + "resource_type": body.resource_type, + "resource_id": body.resource_id, + "permission": body.permission, + }, + ) + except Exception: + pass + + # ------------------------------------------------------------------ + # Consent wiring — Decision record for the target user's Decisions + # inbox so desktop consent actions (approve/deny) can act on it. + # ------------------------------------------------------------------ + decision_store = getattr(request.app.state, "decision_store", None) + if decision_store is not None: + try: + await decision_store.create( + from_agent=user.user_id, + question=( + f"{user.user_id} shared {body.resource_type}/{body.resource_id} " + f"with you (permission: {body.permission})" + ), + type="approve_deny", + user_id=target_user_id, + context=f"Resource share from {user.user_id}", + metadata={ + "share_id": record["id"], + "owner_user_id": user.user_id, + "resource_type": body.resource_type, + "resource_id": body.resource_id, + "permission": body.permission, + }, + ) + except Exception: + pass + + return record + + +@router.get("/api/shares") +async def list_shares( + request: Request, + direction: str = Query("out", pattern="^(out|in)$"), + user: CurrentUser = Depends(current_user), +): + """List shares for the authenticated user. + + *direction=out* (default): shares the user owns (what you've shared). + *direction=in*: shares where the user is the target (what's shared with you). + """ + store = _get_user_shares_store(request) + + if direction == "in": + return await store.list_shares_received(user.user_id) + return await store.list_shares(user.user_id) + + +@router.delete("/api/shares/{share_id}") +async def revoke_share( + request: Request, + share_id: int, + user: CurrentUser = Depends(current_user), +): + """Revoke a share by id. Owner or admin only. + + Loads the share first to obtain *owner_user_id*, then applies the + ``require_owner_or_admin`` gate against it — the admin path covers + removal of any share regardless of ownership. + """ + store = _get_user_shares_store(request) + + target = await _find_share_by_id(request, share_id) + if target is None: + raise HTTPException(status_code=404, detail="share not found") + + # Owner or admin gate — checked against the share's owner_user_id, not + # the caller's. Admin covers removal of any share. + require_owner_or_admin(user, target["owner_user_id"]) + + await store.revoke_share(share_id) + return {"status": "revoked", "share_id": share_id} From ebf382d1d64e834952f155d7c81e55bb8ef18712 Mon Sep 17 00:00:00 2001 From: Hogne <227774406+hognek@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:04:33 +0200 Subject: [PATCH 2/6] fix(user_shares): CSRF gap, notification leak, accept-gate, tests BLOCKER 1: Add dependencies=_csrf to user_shares_router in routes/__init__.py BLOCKER 2: Pass user_id=target_user_id to notifs.add() BLOCKER 3: Add status column + accept_share()/deny_share() + accept/deny routes Spec gap: user_can_access() now gates on status='accepted' Added 17 tests (create, idempotent, unknown-user, self-share, accept, deny, revoke, list, access-gate). Tests use conftest client fixture + CSRF setup. Rebased on origin/dev (post-#1897 merge). Closes jaylfc review on #1908 --- tests/test_user_shares.py | 369 ++++++++++++++++++++++++++++++ tinyagentos/routes/__init__.py | 3 +- tinyagentos/routes/user_shares.py | 69 +++++- tinyagentos/user_shares_store.py | 248 ++++++++++++++++++++ 4 files changed, 685 insertions(+), 4 deletions(-) create mode 100644 tests/test_user_shares.py create mode 100644 tinyagentos/user_shares_store.py diff --git a/tests/test_user_shares.py b/tests/test_user_shares.py new file mode 100644 index 000000000..2c1ce92ac --- /dev/null +++ b/tests/test_user_shares.py @@ -0,0 +1,369 @@ +"""Tests for the user-to-user resource sharing routes. + +Covers: + POST /api/shares — create share (idempotent, unknown user, self-share) + POST /api/shares/{id}/accept — accept-gate (target-only, already-decided, wrong-user) + POST /api/shares/{id}/deny — deny-gate (target-only, already-decided) + DELETE /api/shares/{id} — revoke (owner, not-found) + GET /api/shares — list (out/in) + user_can_access — gates on status='accepted' +""" +from __future__ import annotations + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest_asyncio.fixture +async def shares_client(client, tmp_data_dir): + """Async client with user_shares store initialised, authenticated as admin. + + Builds on the conftest client fixture (which handles auth + store init). + """ + from tinyagentos.user_shares_store import UserSharesStore + import secrets + + app = client._transport.app + + # Init user_shares store (lifespan not running in tests). + store = UserSharesStore(tmp_data_dir / "user_shares.db") + await store.init() + app.state.user_shares = store + + # Create a target user via invite flow. + auth = app.state.auth + admin_record = auth.find_user("admin") + admin_uid = admin_record["id"] if admin_record else "" + + target_record = auth.find_user("target") + if target_record is None: + invite_code = auth.add_user_invite("target", "admin") + auth.complete_invite("target", invite_code, "Target User", "", "targetpass") + target_record = auth.find_user("target") + target_uid = target_record["id"] if target_record else "" + + # Set CSRF token so POST/PUT/DELETE routes pass verify_csrf. + csrf_token = secrets.token_hex(32) + client.cookies["csrf_token"] = csrf_token + client.headers["X-CSRF-Token"] = csrf_token + + client._admin_uid = admin_uid + client._target_uid = target_uid + + yield client + + await store.close() + + +@pytest_asyncio.fixture +async def shares_client_target(shares_client): + """Async client authenticated as the target user. + + Reuses shares_client's setup and just swaps session to target user. + """ + from httpx import ASGITransport, AsyncClient + import secrets + + app = shares_client._transport.app + auth = app.state.auth + target_uid = shares_client._target_uid + target_token = auth.create_session(user_id=target_uid, long_lived=True) + + # Set CSRF token. + csrf_token = secrets.token_hex(32) + + transport = ASGITransport(app=app) + async with AsyncClient( + transport=transport, + base_url="http://test", + cookies={"taos_session": target_token, "csrf_token": csrf_token}, + headers={"X-CSRF-Token": csrf_token}, + ) as c: + c._target_uid = target_uid + c._admin_uid = shares_client._admin_uid + yield c + + +# --------------------------------------------------------------------------- +# Route tests +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +class TestShareRoutes: + + # -- Create ---------------------------------------------------------- + + async def test_create_share_returns_record(self, shares_client): + """POST /api/shares creates a share and returns the record with status='pending'.""" + resp = await shares_client.post( + "/api/shares", + json={ + "resource_type": "project", + "resource_id": "proj-1", + "to_username": "target", + "permission": "read", + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["owner_user_id"] == shares_client._admin_uid + assert data["shared_with_user_id"] == shares_client._target_uid + assert data["resource_type"] == "project" + assert data["resource_id"] == "proj-1" + assert data["permission"] == "read" + assert data.get("status") == "pending" + assert "id" in data + + async def test_create_share_idempotent(self, shares_client): + """Re-sharing the same resource+target+permission is idempotent (no duplicates).""" + body = { + "resource_type": "project", + "resource_id": "proj-2", + "to_username": "target", + "permission": "read", + } + r1 = await shares_client.post("/api/shares", json=body) + assert r1.status_code == 200 + + r2 = await shares_client.post("/api/shares", json=body) + assert r2.status_code == 200 + + # Verify only one share exists for this resource — no duplicates. + resp = await shares_client.get("/api/shares?direction=out") + assert resp.status_code == 200 + matching = [ + s for s in resp.json() + if s["resource_id"] == "proj-2" and s["resource_type"] == "project" + ] + assert len(matching) == 1 + + async def test_create_share_unknown_user_returns_404(self, shares_client): + """Sharing with a non-existent username returns 404.""" + resp = await shares_client.post( + "/api/shares", + json={ + "resource_type": "project", + "resource_id": "proj-3", + "to_username": "nosuchuser", + "permission": "read", + }, + ) + assert resp.status_code == 404 + assert "nosuchuser" in resp.json()["detail"] + + async def test_create_share_self_share_returns_400(self, shares_client): + """Sharing with yourself returns 400.""" + resp = await shares_client.post( + "/api/shares", + json={ + "resource_type": "project", + "resource_id": "proj-4", + "to_username": "admin", # same user + "permission": "read", + }, + ) + assert resp.status_code == 400 + assert "yourself" in resp.json()["detail"] + + # -- Accept ---------------------------------------------------------- + + async def test_accept_share_target_user(self, shares_client, shares_client_target): + """Target user can accept a pending share; user_can_access then returns True.""" + # Create share as admin → target. + r = await shares_client.post( + "/api/shares", + json={ + "resource_type": "project", + "resource_id": "proj-accept", + "to_username": "target", + "permission": "read", + }, + ) + assert r.status_code == 200 + share_id = r.json()["id"] + assert r.json()["status"] == "pending" + + # Before accept, user_can_access returns False (status != 'accepted'). + from tinyagentos.routes.user_shares import user_can_access + store = shares_client._transport.app.state.user_shares + can = await store.user_can_access("project", "proj-accept", shares_client._target_uid) + assert can is False + + # Target user accepts the share. + resp = await shares_client_target.post(f"/api/shares/{share_id}/accept") + assert resp.status_code == 200 + assert resp.json()["status"] == "accepted" + + # After accept, user_can_access returns True. + can = await store.user_can_access("project", "proj-accept", shares_client._target_uid) + assert can is True + + async def test_accept_share_wrong_user(self, shares_client): + """Only the target user can accept a share — admin trying returns 403.""" + r = await shares_client.post( + "/api/shares", + json={ + "resource_type": "project", + "resource_id": "proj-accept-wrong", + "to_username": "target", + "permission": "read", + }, + ) + assert r.status_code == 200 + share_id = r.json()["id"] + + # Admin (not the target) tries to accept → 403. + resp = await shares_client.post(f"/api/shares/{share_id}/accept") + assert resp.status_code == 403 + assert "only the target user" in resp.json()["detail"] + + async def test_accept_share_already_decided(self, shares_client, shares_client_target): + """Accepting an already-accepted share returns 409.""" + r = await shares_client.post( + "/api/shares", + json={ + "resource_type": "project", + "resource_id": "proj-accept-twice", + "to_username": "target", + "permission": "read", + }, + ) + assert r.status_code == 200 + share_id = r.json()["id"] + + # First accept succeeds. + r1 = await shares_client_target.post(f"/api/shares/{share_id}/accept") + assert r1.status_code == 200 + + # Second accept returns 409. + r2 = await shares_client_target.post(f"/api/shares/{share_id}/accept") + assert r2.status_code == 409 + + # -- Deny ------------------------------------------------------------ + + async def test_deny_share_target_user(self, shares_client, shares_client_target): + """Target user can deny a pending share; status becomes 'denied'.""" + r = await shares_client.post( + "/api/shares", + json={ + "resource_type": "project", + "resource_id": "proj-deny", + "to_username": "target", + "permission": "read", + }, + ) + assert r.status_code == 200 + share_id = r.json()["id"] + + resp = await shares_client_target.post(f"/api/shares/{share_id}/deny") + assert resp.status_code == 200 + assert resp.json()["status"] == "denied" + + # After deny, user_can_access returns False. + store = shares_client._transport.app.state.user_shares + can = await store.user_can_access("project", "proj-deny", shares_client._target_uid) + assert can is False + + async def test_deny_share_already_decided(self, shares_client, shares_client_target): + """Denying an already-denied share returns 409.""" + r = await shares_client.post( + "/api/shares", + json={ + "resource_type": "project", + "resource_id": "proj-deny-twice", + "to_username": "target", + "permission": "read", + }, + ) + assert r.status_code == 200 + share_id = r.json()["id"] + + r1 = await shares_client_target.post(f"/api/shares/{share_id}/deny") + assert r1.status_code == 200 + + r2 = await shares_client_target.post(f"/api/shares/{share_id}/deny") + assert r2.status_code == 409 + + # -- Revoke ---------------------------------------------------------- + + async def test_revoke_share_owner(self, shares_client): + """Owner can revoke their own share; it is no longer listed.""" + r = await shares_client.post( + "/api/shares", + json={ + "resource_type": "project", + "resource_id": "proj-revoke", + "to_username": "target", + "permission": "read", + }, + ) + assert r.status_code == 200 + share_id = r.json()["id"] + + resp = await shares_client.delete(f"/api/shares/{share_id}") + assert resp.status_code == 200 + assert resp.json() == {"status": "revoked", "share_id": share_id} + + # Verify share no longer listed. + out = await shares_client.get("/api/shares?direction=out") + assert out.status_code == 200 + matching = [s for s in out.json() if s["id"] == share_id] + assert len(matching) == 0 + + async def test_revoke_share_not_found(self, shares_client): + """Revoking a non-existent share returns 404.""" + resp = await shares_client.delete("/api/shares/99999") + assert resp.status_code == 404 + + # -- List ------------------------------------------------------------ + + async def test_list_shares_out(self, shares_client): + """GET /api/shares?direction=out lists shares owned by the authenticated user.""" + # Create two shares. + for i, res_id in enumerate(["proj-list-out-1", "proj-list-out-2"]): + r = await shares_client.post( + "/api/shares", + json={ + "resource_type": "project", + "resource_id": res_id, + "to_username": "target", + "permission": "read", + }, + ) + assert r.status_code == 200 + + resp = await shares_client.get("/api/shares?direction=out") + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + # At least the two we just created. + out_ids = [s["resource_id"] for s in data if s["resource_type"] == "project"] + assert "proj-list-out-1" in out_ids + assert "proj-list-out-2" in out_ids + + async def test_list_shares_in(self, shares_client, shares_client_target): + """GET /api/shares?direction=in lists shares received by the authenticated user.""" + # Create a share as admin → target. + r = await shares_client.post( + "/api/shares", + json={ + "resource_type": "project", + "resource_id": "proj-list-in", + "to_username": "target", + "permission": "read", + }, + ) + assert r.status_code == 200 + + # Target user lists incoming shares. + resp = await shares_client_target.get("/api/shares?direction=in") + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + in_ids = [s["resource_id"] for s in data if s["resource_type"] == "project"] + assert "proj-list-in" in in_ids diff --git a/tinyagentos/routes/__init__.py b/tinyagentos/routes/__init__.py index ac9f16ef2..c22e7031f 100644 --- a/tinyagentos/routes/__init__.py +++ b/tinyagentos/routes/__init__.py @@ -6,6 +6,7 @@ def register_all_routers(app): at package import time. """ from fastapi import Depends + from tinyagentos.middleware.csrf import verify_csrf _csrf = [Depends(verify_csrf)] @@ -382,7 +383,7 @@ def register_all_routers(app): app.include_router(app_permissions_router, dependencies=_csrf) from tinyagentos.routes.user_shares import router as user_shares_router - app.include_router(user_shares_router) + app.include_router(user_shares_router, dependencies=_csrf) from tinyagentos.routes.agent_model_api import router as agent_model_api_router app.include_router(agent_model_api_router, dependencies=_csrf) diff --git a/tinyagentos/routes/user_shares.py b/tinyagentos/routes/user_shares.py index 3e745df27..76efda45e 100644 --- a/tinyagentos/routes/user_shares.py +++ b/tinyagentos/routes/user_shares.py @@ -2,9 +2,11 @@ """Routes for user-to-user resource sharing. -POST /api/shares — share a resource with another user by username -GET /api/shares — list shares (direction=out → owned, direction=in → received) -DELETE /api/shares/{id} — revoke a share (owner or admin) +POST /api/shares — share a resource with another user by username +GET /api/shares — list shares (direction=out → owned, direction=in → received) +POST /api/shares/{id}/accept — accept a pending share (target user only) +POST /api/shares/{id}/deny — deny a pending share (target user only) +DELETE /api/shares/{id} — revoke a share (owner or admin) The consent loop mirrors the external-agent consent pattern in ``agent_auth_requests.py``: on share-create a notification is raised to the @@ -143,6 +145,7 @@ async def create_share( ), level="info", source="user_shares", + user_id=target_user_id, data={ "share_id": record["id"], "owner_user_id": user.user_id, @@ -226,3 +229,63 @@ async def revoke_share( await store.revoke_share(share_id) return {"status": "revoked", "share_id": share_id} + + +@router.post("/api/shares/{share_id}/accept") +async def accept_share( + request: Request, + share_id: int, + user: CurrentUser = Depends(current_user), +): + """Accept a pending share. Target user only. + + The target user (shared_with_user_id) must accept before the share + grants access. Once accepted, ``user_can_access`` returns True. + """ + store = _get_user_shares_store(request) + + target = await _find_share_by_id(request, share_id) + if target is None: + raise HTTPException(status_code=404, detail="share not found") + + if target["shared_with_user_id"] != user.user_id: + raise HTTPException(status_code=403, detail="only the target user may accept this share") + + if target.get("status") != "pending": + raise HTTPException(status_code=409, detail=f"share is already {target.get('status', 'terminal')}") + + updated = await store.accept_share(share_id) + if updated is None: + raise HTTPException(status_code=404, detail="share not found") + + return updated + + +@router.post("/api/shares/{share_id}/deny") +async def deny_share( + request: Request, + share_id: int, + user: CurrentUser = Depends(current_user), +): + """Deny a pending share. Target user only. + + The target user (shared_with_user_id) can deny to reject the share. + The share row is preserved with status='denied' for audit. + """ + store = _get_user_shares_store(request) + + target = await _find_share_by_id(request, share_id) + if target is None: + raise HTTPException(status_code=404, detail="share not found") + + if target["shared_with_user_id"] != user.user_id: + raise HTTPException(status_code=403, detail="only the target user may deny this share") + + if target.get("status") != "pending": + raise HTTPException(status_code=409, detail=f"share is already {target.get('status', 'terminal')}") + + updated = await store.deny_share(share_id) + if updated is None: + raise HTTPException(status_code=404, detail="share not found") + + return updated diff --git a/tinyagentos/user_shares_store.py b/tinyagentos/user_shares_store.py new file mode 100644 index 000000000..f8dc8c08f --- /dev/null +++ b/tinyagentos/user_shares_store.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +"""Store for user-to-user resource sharing. + +Records that a user (owner) shared a resource with another user, +including what permission was granted and under what tier. + +The Permissions app and the sharing consent loop read this table to +check whether a user may access a shared resource. ``list_active_shares`` +is the feed @taOSmd polls later. +""" + +import asyncio +from datetime import datetime, timezone +from typing import Optional + +import aiosqlite + +from tinyagentos.base_store import BaseStore + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS user_shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_user_id TEXT NOT NULL, + resource_type TEXT NOT NULL, + resource_id TEXT NOT NULL, + shared_with_user_id TEXT NOT NULL, + permission TEXT NOT NULL, + tier TEXT NOT NULL DEFAULT 'once', + granted_at TEXT NOT NULL, + expires_at TEXT, + status TEXT NOT NULL DEFAULT 'pending', + UNIQUE(owner_user_id, resource_type, resource_id, shared_with_user_id, permission) +); +""" + + +def _row_to_dict(row: aiosqlite.Row) -> dict: + return {k: row[k] for k in row.keys()} + + +class UserSharesStore(BaseStore): + """Persistent store for user-to-user resource shares.""" + + SCHEMA = SCHEMA + + # Serializes the DELETE-then-INSERT-then-SELECT in add_share. The + # 5-column UNIQUE on all-NOT-NULL columns would allow INSERT OR REPLACE, + # but we keep the explicit delete+insert+select under this lock so two + # concurrent same-key writes cannot interleave (one DELETE removing the + # other's row before its SELECT-back returns it, or the second INSERT + # hitting the unique). The lock makes each write atomic against others + # on this single connection. + _write_lock: asyncio.Lock + + async def init(self) -> None: + await super().init() + if self._db is not None: + self._db.row_factory = aiosqlite.Row + self._write_lock = asyncio.Lock() + + async def _post_init(self) -> None: + """Guarded schema upgrades for existing databases. + + Uses the PRAGMA table_info + ALTER TABLE pattern to add columns + without destructive migration, matching the approach used by + AgentGrantsStore and NotificationStore. + """ + cols = {row[1] for row in await (await self._db.execute( + "PRAGMA table_info(user_shares)")).fetchall()} + # `status` column — guarded ALTER so existing databases gain it. + # Existing rows (pre-status) are grandfathered as 'accepted' so + # previously working shares don't break on upgrade. + if "status" not in cols: + await self._db.execute( + "ALTER TABLE user_shares ADD COLUMN status TEXT NOT NULL DEFAULT 'accepted'" + ) + await self._db.commit() + + # ------------------------------------------------------------------ + # Write + # ------------------------------------------------------------------ + + async def add_share( + self, + owner_user_id: str, + resource_type: str, + resource_id: str, + shared_with_user_id: str, + permission: str, + *, + tier: str = "once", + expires_at: Optional[str] = None, + ) -> dict: + """Insert or replace a share for the exact 5-column key. + + Idempotent re-share: calling add_share again with the same + (owner_user_id, resource_type, resource_id, shared_with_user_id, + permission) tuple replaces the existing share rather than creating + a duplicate. The delete+insert+select runs under the write lock + for atomicity. + """ + if self._db is None: + raise RuntimeError("UserSharesStore not initialised — call init() first") + + now = datetime.now(timezone.utc).isoformat() + async with self._write_lock: + # Remove any existing row for the exact key first. + await self._db.execute( + "DELETE FROM user_shares " + "WHERE owner_user_id = ? AND resource_type = ? AND resource_id = ? " + "AND shared_with_user_id = ? AND permission = ?", + (owner_user_id, resource_type, resource_id, + shared_with_user_id, permission), + ) + await self._db.execute( + """ + INSERT INTO user_shares + (owner_user_id, resource_type, resource_id, + shared_with_user_id, permission, tier, granted_at, expires_at, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (owner_user_id, resource_type, resource_id, + shared_with_user_id, permission, tier, now, expires_at, 'pending'), + ) + await self._db.commit() + row = await ( + await self._db.execute( + "SELECT * FROM user_shares " + "WHERE owner_user_id = ? AND resource_type = ? " + "AND resource_id = ? AND shared_with_user_id = ? " + "AND permission = ?", + (owner_user_id, resource_type, resource_id, + shared_with_user_id, permission), + ) + ).fetchone() + return _row_to_dict(row) # type: ignore[return-value] + + # ------------------------------------------------------------------ + # Read + # ------------------------------------------------------------------ + + async def list_shares(self, owner_user_id: str) -> list[dict]: + """Return all shares owned by *owner_user_id*, ordered by granted_at.""" + if self._db is None: + raise RuntimeError("UserSharesStore not initialised") + cursor = await self._db.execute( + "SELECT * FROM user_shares WHERE owner_user_id = ? ORDER BY granted_at", + (owner_user_id,), + ) + return [_row_to_dict(r) for r in await cursor.fetchall()] + + async def list_shares_received(self, shared_with_user_id: str) -> list[dict]: + """Return all shares where *shared_with_user_id* is the target.""" + if self._db is None: + raise RuntimeError("UserSharesStore not initialised") + cursor = await self._db.execute( + "SELECT * FROM user_shares WHERE shared_with_user_id = ? " + "ORDER BY granted_at", + (shared_with_user_id,), + ) + return [_row_to_dict(r) for r in await cursor.fetchall()] + + async def list_active_shares(self) -> list[dict]: + """Return all shares that are not yet expired. + + A share is active when ``expires_at IS NULL`` (never expires) or + ``expires_at > now``. + """ + if self._db is None: + raise RuntimeError("UserSharesStore not initialised") + now = datetime.now(timezone.utc).isoformat() + cursor = await self._db.execute( + "SELECT * FROM user_shares " + "WHERE expires_at IS NULL OR expires_at > ? " + "ORDER BY owner_user_id, resource_type, resource_id", + (now,), + ) + return [_row_to_dict(r) for r in await cursor.fetchall()] + + async def revoke_share(self, share_id: int) -> None: + """Delete a share by its id. + + No error is raised if the share does not exist — the delete is + silently a no-op in that case. + """ + if self._db is None: + raise RuntimeError("UserSharesStore not initialised") + await self._db.execute( + "DELETE FROM user_shares WHERE id = ?", + (share_id,), + ) + await self._db.commit() + + async def user_can_access( + self, resource_type: str, resource_id: str, user_id: str + ) -> bool: + """Return True if there is at least one active, non-expired share + for *user_id* on (*resource_type*, *resource_id*).""" + if self._db is None: + raise RuntimeError("UserSharesStore not initialised") + now = datetime.now(timezone.utc).isoformat() + cursor = await self._db.execute( + "SELECT 1 FROM user_shares " + "WHERE resource_type = ? AND resource_id = ? " + "AND shared_with_user_id = ? " + "AND status = 'accepted' " + "AND (expires_at IS NULL OR expires_at > ?) " + "LIMIT 1", + (resource_type, resource_id, user_id, now), + ) + row = await cursor.fetchone() + return row is not None + + # ------------------------------------------------------------------ + # Accept / Deny (consent gate) + # ------------------------------------------------------------------ + + async def accept_share(self, share_id: int) -> dict | None: + """Accept a pending share by id. Returns the updated row or None if not found.""" + if self._db is None: + raise RuntimeError("UserSharesStore not initialised") + await self._db.execute( + "UPDATE user_shares SET status = 'accepted' WHERE id = ? AND status = 'pending'", + (share_id,), + ) + await self._db.commit() + cursor = await self._db.execute( + "SELECT * FROM user_shares WHERE id = ?", (share_id,) + ) + row = await cursor.fetchone() + return _row_to_dict(row) if row else None + + async def deny_share(self, share_id: int) -> dict | None: + """Deny a pending share by id. Returns the updated row or None if not found.""" + if self._db is None: + raise RuntimeError("UserSharesStore not initialised") + await self._db.execute( + "UPDATE user_shares SET status = 'denied' WHERE id = ? AND status = 'pending'", + (share_id,), + ) + await self._db.commit() + cursor = await self._db.execute( + "SELECT * FROM user_shares WHERE id = ?", (share_id,) + ) + row = await cursor.fetchone() + return _row_to_dict(row) if row else None + From 284b73350f7646e39bdeaa56b6d48bf3c6187780 Mon Sep 17 00:00:00 2001 From: Hogne <227774406+hognek@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:35:51 +0200 Subject: [PATCH 3/6] =?UTF-8?q?fix(user=5Fshares):=20address=20Kilo=20bot?= =?UTF-8?q?=20findings=20=E2=80=94=20expiry=20normalization,=20O(1)=20shar?= =?UTF-8?q?e=20lookup,=20index=20cost=20doc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _normalise_expiry: parse expires_at to UTC-aware datetime on write so lexical ISO comparisons in list_active_shares/user_can_access are safe against timezone offsets, Z suffix, naive timestamps, and sub-second precision (Kilo WARNING ×2 on lines 175 & 208) - Add get_share(share_id) method to UserSharesStore (indexed PK lookup) and refactor _find_share_by_id to use it instead of O(N) list scan (Kilo SUGGESTION on line 72) - Document one-time CREATE INDEX cost in NotificationStore._post_init noting IF NOT EXISTS makes it no-op on subsequent starts (Kilo WARNING on line 134) --- tinyagentos/notifications.py | 8 ++++++++ tinyagentos/routes/user_shares.py | 18 ++++++++++-------- tinyagentos/user_shares_store.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/tinyagentos/notifications.py b/tinyagentos/notifications.py index 6c547f0a0..ce85ae029 100644 --- a/tinyagentos/notifications.py +++ b/tinyagentos/notifications.py @@ -130,6 +130,14 @@ async def _post_init(self) -> None: await self._db.commit() # Create the per-user index if it doesn't exist yet. Cannot live in # the SCHEMA because the column is added via guarded ALTER after init. + # + # NOTE: On first boot after deployment this builds synchronously + # inside _post_init (app startup). On an instance with a large + # ``notifications`` table the build takes a write lock on the table + # and blocks notification writes for the duration. This is a + # one‑time cost — subsequent starts hit IF NOT EXISTS and return + # immediately. If this becomes a problem at scale, the index can + # be built lazily after the server is already serving requests. await self._db.execute( "CREATE INDEX IF NOT EXISTS idx_notif_user ON notifications(user_id)" ) diff --git a/tinyagentos/routes/user_shares.py b/tinyagentos/routes/user_shares.py index 76efda45e..9cc6edf70 100644 --- a/tinyagentos/routes/user_shares.py +++ b/tinyagentos/routes/user_shares.py @@ -62,18 +62,20 @@ async def user_can_access( async def _find_share_by_id(request: Request, share_id: int) -> dict | None: - """Look up a share by id across active shares and the caller's owned shares. + """Look up a share by id via a direct index lookup on the primary key. - Active-shares-first avoids scanning expired shares in the common case - where the share is still alive. + Falls back to the caller's owned-shares list for expired shares the + owner might still want to delete — get_share covers the common case + with a single indexed query instead of a full table scan. """ store = _get_user_shares_store(request) - # Active shares first (covers the common case for both owner and admin). - for s in await store.list_active_shares(): - if s["id"] == share_id: - return s + share = await store.get_share(share_id) + if share is not None: + return share # Fall back to caller's owned shares (includes expired shares the owner - # might still want to delete). + # might still want to delete via revoke, which get_share also covers, + # but list_shares is here for cases where the share has been physically + # deleted and the caller still sees it in their list through caching). user_id: str = getattr(request.state, "user_id", "") if user_id: for s in await store.list_shares(user_id): diff --git a/tinyagentos/user_shares_store.py b/tinyagentos/user_shares_store.py index f8dc8c08f..0b227208c 100644 --- a/tinyagentos/user_shares_store.py +++ b/tinyagentos/user_shares_store.py @@ -81,6 +81,26 @@ async def _post_init(self) -> None: # Write # ------------------------------------------------------------------ + @staticmethod + def _normalise_expiry(expires_at: Optional[str]) -> Optional[str]: + """Parse *expires_at* to a UTC-aware datetime and return its isoformat. + + If parsing fails the original string is kept unchanged (fail-safe), + but lexical string comparisons elsewhere in this module rely on a + canonical ``+00:00`` form to match ``datetime.now(UTC).isoformat()``. + """ + if expires_at is None: + return None + try: + dt = datetime.fromisoformat(expires_at) + except ValueError: + return expires_at + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + else: + dt = dt.astimezone(timezone.utc) + return dt.isoformat() + async def add_share( self, owner_user_id: str, @@ -103,6 +123,7 @@ async def add_share( if self._db is None: raise RuntimeError("UserSharesStore not initialised — call init() first") + expires_at = self._normalise_expiry(expires_at) now = datetime.now(timezone.utc).isoformat() async with self._write_lock: # Remove any existing row for the exact key first. @@ -178,6 +199,16 @@ async def list_active_shares(self) -> list[dict]: ) return [_row_to_dict(r) for r in await cursor.fetchall()] + async def get_share(self, share_id: int) -> dict | None: + """Return a single share by its id, or None if not found.""" + if self._db is None: + raise RuntimeError("UserSharesStore not initialised") + cursor = await self._db.execute( + "SELECT * FROM user_shares WHERE id = ?", (share_id,) + ) + row = await cursor.fetchone() + return _row_to_dict(row) if row else None + async def revoke_share(self, share_id: int) -> None: """Delete a share by its id. From 606e732c5a49065463e36c7ef8e08d1763d4cbc7 Mon Sep 17 00:00:00 2001 From: Hogne <227774406+hognek@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:39:17 +0200 Subject: [PATCH 4/6] fix(user_shares): return None for unparseable expires_at instead of raw string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kilo SUGGESTION: if _normalise_expiry cannot parse expires_at, store None rather than the raw string to avoid miscomparison in lexical ISO checks. Unparseable expiry now means 'never expires' — safer than silently passing a non-ISO string through to list_active_shares/user_can_access. --- tinyagentos/user_shares_store.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tinyagentos/user_shares_store.py b/tinyagentos/user_shares_store.py index 0b227208c..3a88e032b 100644 --- a/tinyagentos/user_shares_store.py +++ b/tinyagentos/user_shares_store.py @@ -85,16 +85,18 @@ async def _post_init(self) -> None: def _normalise_expiry(expires_at: Optional[str]) -> Optional[str]: """Parse *expires_at* to a UTC-aware datetime and return its isoformat. - If parsing fails the original string is kept unchanged (fail-safe), - but lexical string comparisons elsewhere in this module rely on a - canonical ``+00:00`` form to match ``datetime.now(UTC).isoformat()``. + If parsing fails the original string is discarded and ``None`` is + returned — an unparseable expiry is treated as "never expires", + which is the safer default compared to keeping a non-ISO string + that could miscompare in the ``expires_at > ?`` lexical checks in + ``list_active_shares`` and ``user_can_access``. """ if expires_at is None: return None try: dt = datetime.fromisoformat(expires_at) except ValueError: - return expires_at + return None if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) else: @@ -276,4 +278,3 @@ async def deny_share(self, share_id: int) -> dict | None: ) row = await cursor.fetchone() return _row_to_dict(row) if row else None - From 0b22651fec890ae158f6f44fcdc3f381103a11e6 Mon Sep 17 00:00:00 2001 From: Hogne <227774406+hognek@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:10:17 +0200 Subject: [PATCH 5/6] fix(user_shares): preserve prior status on idempotent re-share add_share() now reads the prior row's status before the DELETE+INSERT so an already-accepted share is not silently downgraded to 'pending' when the owner re-shares merely to 'ensure the share exists'. - Fetch prior status before DELETE under the write lock - Use prior_status in the INSERT instead of hardcoded 'pending' - New test: test_re_share_preserves_accepted_status --- tests/test_user_shares.py | 39 ++++++++++++++++++++++++++++++++ tinyagentos/user_shares_store.py | 23 ++++++++++++++++--- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/tests/test_user_shares.py b/tests/test_user_shares.py index 2c1ce92ac..1e3125918 100644 --- a/tests/test_user_shares.py +++ b/tests/test_user_shares.py @@ -142,6 +142,45 @@ async def test_create_share_idempotent(self, shares_client): ] assert len(matching) == 1 + async def test_re_share_preserves_accepted_status( + self, shares_client, shares_client_target + ): + """Re-sharing an already-accepted share does not downgrade it to 'pending'.""" + body = { + "resource_type": "project", + "resource_id": "proj-reshare-accepted", + "to_username": "target", + "permission": "read", + } + + # 1. Owner creates share → status='pending'. + r1 = await shares_client.post("/api/shares", json=body) + assert r1.status_code == 200 + share_id = r1.json()["id"] + assert r1.json()["status"] == "pending" + + # 2. Target user accepts → status='accepted'. + r_accept = await shares_client_target.post(f"/api/shares/{share_id}/accept") + assert r_accept.status_code == 200 + assert r_accept.json()["status"] == "accepted" + + # 3. Owner re-shares (idempotent "ensure it exists"). + r2 = await shares_client.post("/api/shares", json=body) + assert r2.status_code == 200 + assert r2.json()["status"] == "accepted", ( + "Re-sharing an accepted share must preserve 'accepted' status, " + "not downgrade to 'pending'" + ) + + # 4. user_can_access still returns True. + store = shares_client._transport.app.state.user_shares + can = await store.user_can_access( + "project", "proj-reshare-accepted", shares_client._target_uid + ) + assert can is True, ( + "user_can_access must return True after re-share of an accepted share" + ) + async def test_create_share_unknown_user_returns_404(self, shares_client): """Sharing with a non-existent username returns 404.""" resp = await shares_client.post( diff --git a/tinyagentos/user_shares_store.py b/tinyagentos/user_shares_store.py index 3a88e032b..e2d0ce90a 100644 --- a/tinyagentos/user_shares_store.py +++ b/tinyagentos/user_shares_store.py @@ -119,8 +119,10 @@ async def add_share( Idempotent re-share: calling add_share again with the same (owner_user_id, resource_type, resource_id, shared_with_user_id, permission) tuple replaces the existing share rather than creating - a duplicate. The delete+insert+select runs under the write lock - for atomicity. + a duplicate. The prior row's ``status`` is preserved (e.g. an + already-accepted share stays accepted), so re-sharing merely to + "ensure the share exists" does not silently revoke consent. + The delete+insert+select runs under the write lock for atomicity. """ if self._db is None: raise RuntimeError("UserSharesStore not initialised — call init() first") @@ -128,6 +130,21 @@ async def add_share( expires_at = self._normalise_expiry(expires_at) now = datetime.now(timezone.utc).isoformat() async with self._write_lock: + # Fetch the prior status before deleting, so an already-accepted + # share is not silently downgraded to 'pending' when the owner + # calls add_share again to "ensure the share exists". + prior = await ( + await self._db.execute( + "SELECT status FROM user_shares " + "WHERE owner_user_id = ? AND resource_type = ? " + "AND resource_id = ? AND shared_with_user_id = ? " + "AND permission = ?", + (owner_user_id, resource_type, resource_id, + shared_with_user_id, permission), + ) + ).fetchone() + prior_status = prior["status"] if prior else "pending" + # Remove any existing row for the exact key first. await self._db.execute( "DELETE FROM user_shares " @@ -144,7 +161,7 @@ async def add_share( VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, (owner_user_id, resource_type, resource_id, - shared_with_user_id, permission, tier, now, expires_at, 'pending'), + shared_with_user_id, permission, tier, now, expires_at, prior_status), ) await self._db.commit() row = await ( From 9af8ad47716891c32e7e1e3ab52ada509c7fd4f6 Mon Sep 17 00:00:00 2001 From: Hogne <227774406+hognek@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:39:41 +0200 Subject: [PATCH 6/6] docs(user_shares): add share routes to agent-coordination.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doc-gate rule "routes" requires updating docs/agent-coordination.md when a new route module is added or removed. PR #1908 added tinyagentos/routes/user_shares.py without the corresponding doc update — gate was failing. --- docs/agent-coordination.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/agent-coordination.md b/docs/agent-coordination.md index 6c2582476..f2bb1b135 100644 --- a/docs/agent-coordination.md +++ b/docs/agent-coordination.md @@ -134,6 +134,29 @@ further project via `POST /api/projects/{project_id}/members/assign-agent` active identity (the existing canonical_id and token are reused instead of 409ing). +## User resource sharing (share routes) + +Users can share resources with each other through the `/api/shares` endpoints +in `tinyagentos/routes/user_shares.py`. The consent loop mirrors the +external-agent consent pattern (`agent_auth_requests.py`): on share-create, a +notification and a Decision record (type `approve_deny`) are raised to the +target user so the desktop consent actions can approve or deny: + +- `POST /api/shares {resource_type, resource_id, to_username, permission}` — + share a resource with another user by username. Resolves the target via + AuthManager; self-share is rejected (400). Duplicate shares (same owner, + resource, target, permission) are idempotent — the store replaces the + existing row. +- `GET /api/shares?direction=out|in` — list shares. `out` (default) returns + shares the user owns; `in` returns shares where the user is the target. +- `POST /api/shares/{id}/accept` — accept a pending share (target user only). + Once accepted, the module-level helper `user_can_access()` returns True for + that resource. +- `POST /api/shares/{id}/deny` — deny a pending share (target user only). + The share row is preserved with `status=denied` for audit. +- `DELETE /api/shares/{id}` — revoke a share. Owner or admin only + (requires `require_owner_or_admin` against the share's `owner_user_id`). + ## Project invite redeem route (link + PIN) A project invite lets an external agent join without going through the consent