diff --git a/.env.example b/.env.example index f01ad00..1ddd1a0 100644 --- a/.env.example +++ b/.env.example @@ -12,13 +12,31 @@ API_HOST=0.0.0.0 API_PORT=8000 API_ENV=development -# Secret used to sign session tokens — change this in production +# Secret used to sign session tokens — MUST change in production. +# Generate with: python -c "import secrets; print(secrets.token_urlsafe(48))" API_SECRET_KEY=replace-with-a-long-random-secret +# Shared operator API key — all operators present this key to log in. +# MUST change in production. +# Generate with: python -c "import secrets; print(secrets.token_urlsafe(32))" +OPERATOR_API_KEY=replace-with-operator-key + +# Operator session token lifetime (hours). Default: 8 +# OPERATOR_TOKEN_EXPIRE_HOURS=8 + +# Auth posture for read endpoints. +# true (default/production): all list/detail endpoints require operator JWT. +# false (demo mode): endpoints are publicly readable without auth. +# Set to false for open demo setups or local development. +REQUIRE_AUTH_FOR_READS=false + # ───────────────────────────────────────────── # Database (apps/api) # ───────────────────────────────────────────── -DATABASE_URL=postgresql://agentlink:agentlink@localhost:5432/agentlink_dev +# Use the async driver matching your database: +# PostgreSQL: postgresql+asyncpg://user:pass@host:5432/dbname +# SQLite (dev only): sqlite+aiosqlite:///./agentlink.db +DATABASE_URL=postgresql+asyncpg://agentlink:agentlink@localhost:5432/agentlink_dev # ───────────────────────────────────────────── # Redis (presence, queues, transient state) diff --git a/.gitignore b/.gitignore index 58bb059..fa224ff 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ out/ .env.local .env.*.local !.env.example +.claude/ # Python __pycache__/ diff --git a/Makefile b/Makefile index 03c6e3b..d993f97 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ # - Docker + Docker Compose .PHONY: help install build dev typecheck lint test coverage validate release-gate clean \ - api-install api-dev api-migrate \ + api-install api-dev api-migrate api-seed-demo \ api-test api-coverage \ docker-up docker-down docker-build \ bootstrap @@ -31,7 +31,9 @@ help: @echo " clean Remove build artifacts and node_modules" @echo "" @echo " api-install Install Python deps for apps/api" + @echo " api-migrate Apply Alembic migrations (alembic upgrade head)" @echo " api-dev Start FastAPI dev server" + @echo " api-seed-demo Seed local demo data (nodes, agents, tasks, audit events)" @echo " api-test Run Python API tests (pytest with coverage)" @echo " api-coverage Alias for api-test (coverage always included)" @echo "" @@ -84,6 +86,12 @@ clean: api-install: cd apps/api && pip install -r requirements.txt +api-migrate: + cd apps/api && alembic upgrade head + +api-seed-demo: + cd apps/api && python scripts/seed_demo.py + api-test: cd apps/api && python -m pytest diff --git a/README.md b/README.md index 73b4f6f..969a04c 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,22 @@ AgentLink is a platform for linking AI agent ecosystems across devices and runti ## Current status -**Pre-release MVP. Not yet stable for production use.** +**Multi-user MVP. Open-source release candidate.** -- The core server, web UI, node runtime, and protocol types are implemented and tested. -- CI and the release gate pass end-to-end. -- Some architecture documentation describes the target direction, not just the implemented state. -- The public stable release is not yet cut. Expect breaking changes. +What works today: + +- Multi-user organizations with RBAC (owner/admin/operator/viewer) +- Invite-based onboarding with short-lived tokens +- Trust link foundation for peer-to-peer node relationships +- Node enrollment, heartbeat, and capability discovery +- Task creation, routing, policy evaluation, and approval gating +- Artifact storage (inline and file-backed) with retention policies +- Immutable audit trail with keyset pagination +- Operator dashboard with real-time node activity and task lifecycle views +- Retry semantics with exponential backoff (automatic and manual) +- WebSocket dispatch channel for live task notifications + +The public stable release is not yet cut. Expect breaking changes. --- @@ -47,13 +57,15 @@ The initial focus is **trusted-circle collaboration**: two or more users securel | `scripts/release-gate.js` | Full-stack validation script (single source of CI truth) | | `.github/workflows/ci.yml` | CI pipeline | -**Not yet present (planned):** +**Planned (not yet present):** - `packages/sdk-js/` and `packages/sdk-python/` — public SDKs - `adapters/generic-websocket/` — WebSocket adapter - `apps/desktop/` — optional desktop shell - `services/relay/` — relay service for NAT traversal -- Multi-org and team management features +- Cross-peer task routing and federation +- Per-node capability-aware routing +- Production deployment guides --- @@ -153,27 +165,57 @@ Full type definitions live in `packages/protocol/src/`. ## Getting started -**Prerequisites:** Node >= 20, pnpm >= 9, Python >= 3.11. +**Prerequisites:** Node >= 20, pnpm >= 9, Python >= 3.11, Docker (for PostgreSQL). + +### Quick start ```bash -# Clone and install JS/TS dependencies +# 1. Clone and install git clone https://github.com/jakesterns/AgentLink.git cd agentlink pnpm install - -# Install Python API dependencies pip install -r apps/api/requirements.txt -``` -**Build:** +# 2. Start PostgreSQL (or use an existing instance) +docker compose -f docker-compose.dev.yml up -d postgres -```bash -pnpm run build +# 3. Apply database migrations +cd apps/api && alembic upgrade head + +# 4. Seed demo data (nodes, agents, tasks, audit events) +python scripts/seed_demo.py + +# 5. Start the API server +uvicorn main:app --reload --host 0.0.0.0 --port 8000 & + +# 6. Start the web dashboard (from repo root) +cd ../.. && pnpm --filter @agentlink/web dev ``` -**Typecheck:** +Open to see the dashboard with seeded demo data. + +### Demo credentials + +| Field | Value | +| ------------ | ---------------------------------------- | +| Operator ID | `demo` | +| API Key | `dev-operator-key-change-in-production` | + +Log in at `/operator/login` to access approvals, audit log, and org management. + +### Demo walkthrough + +1. **Dashboard** — See enrolled nodes, active tasks, and pending approvals +2. **Nodes** — View node status (online/offline), capabilities, and queue health +3. **Submit Task** — Create a task at `/tasks/new` with type `research` and any instruction +4. **Approvals** — Approve or reject tasks requiring human approval +5. **Audit Log** — View the full audit trail of system actions +6. **Task Detail** — Click any task to see its lifecycle, artifacts, and routing decisions + +### Build and typecheck ```bash +pnpm run build pnpm run typecheck ``` @@ -233,19 +275,33 @@ See [SECURITY.md](SECURITY.md) for the vulnerability reporting process. --- +## Multi-user and organizations + +AgentLink supports multiple operators working in shared workspaces: + +- **Organizations** — shared scoping for all resources (nodes, tasks, artifacts, audit events) +- **RBAC roles** — owner, admin, operator, viewer with hierarchical permissions +- **Invite flow** — admin creates a short-lived invite token; recipient accepts to join the org +- **Trust links** — directional peer trust between nodes (foundation for cross-node routing) + +See [docs/auth/rbac.md](docs/auth/rbac.md) and [docs/auth/organizations.md](docs/auth/organizations.md) for details. + ## Roadmap Post-MVP directions include: +- Cross-peer task routing via trust links +- Full trust-link federation between orgs - Public JS and Python SDKs (`packages/sdk-js/`, `packages/sdk-python/`) - Additional adapters (LangGraph, MCP, WebSocket) - Smarter routing (cost-aware, latency-aware, privacy-aware dispatch) -- Team and organization features +- Richer org management UI (org switcher, member management dashboard) +- Per-node capability-aware routing - Desktop shell (`apps/desktop/`) - Relay service for NAT traversal -- Adapter conformance test suite +- Production deployment guides -Nothing above exists yet. Major additions should be proposed via a GitHub Discussion or issue before implementation. +Major additions should be proposed via a GitHub Discussion or issue before implementation. --- diff --git a/apps/api/.coverage b/apps/api/.coverage index 24b30d4..f12681a 100644 Binary files a/apps/api/.coverage and b/apps/api/.coverage differ diff --git a/apps/api/alembic/env.py b/apps/api/alembic/env.py index a37b763..7840129 100644 --- a/apps/api/alembic/env.py +++ b/apps/api/alembic/env.py @@ -53,6 +53,16 @@ def run_migrations_offline() -> None: context.run_migrations() +def do_run_migrations(connection) -> None: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + + async def run_migrations_online() -> None: """ Run migrations in 'online' mode with a live async database connection. @@ -60,14 +70,7 @@ async def run_migrations_online() -> None: connectable = create_async_engine(settings.database_url, echo=False) async with connectable.connect() as connection: - await connection.run_sync( - lambda sync_conn: context.configure( - connection=sync_conn, - target_metadata=target_metadata, - compare_type=True, - ) - ) - await connection.run_sync(lambda _: context.run_migrations()) + await connection.run_sync(do_run_migrations) await connectable.dispose() diff --git a/apps/api/alembic/versions/012_multi_user_orgs.py b/apps/api/alembic/versions/012_multi_user_orgs.py new file mode 100644 index 0000000..484b92d --- /dev/null +++ b/apps/api/alembic/versions/012_multi_user_orgs.py @@ -0,0 +1,119 @@ +"""Multi-user: organizations, memberships, invites, trust links + +Adds the multi-user authorization model: + - organizations table: shared workspaces scoping all resources + - org_memberships table: RBAC role bindings (owner/admin/operator/viewer) + - org_invites table: short-lived invite tokens for org onboarding + - trust_links table: directional peer trust between nodes + - org_id columns on tasks and audit_events for org scoping + +Revision ID: 012 +Revises: 011 +Create Date: 2026-03-31 00:00:00.000000 +""" +from __future__ import annotations + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "012" +down_revision: Union[str, None] = "011" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ── Organizations ──────────────────────────────────────────────────────── + op.create_table( + "organizations", + sa.Column("org_id", sa.String(64), primary_key=True), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("slug", sa.String(128), nullable=False, unique=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_organizations_slug", "organizations", ["slug"], unique=True) + + # ── Organization memberships ───────────────────────────────────────────── + op.create_table( + "org_memberships", + sa.Column("membership_id", sa.String(64), primary_key=True), + sa.Column( + "org_id", + sa.String(64), + sa.ForeignKey("organizations.org_id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("operator_id", sa.String(128), nullable=False), + sa.Column("role", sa.String(32), nullable=False, server_default="operator"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_org_memberships_org_id", "org_memberships", ["org_id"]) + op.create_index("ix_org_memberships_operator_id", "org_memberships", ["operator_id"]) + op.create_index( + "ix_org_memberships_org_operator", + "org_memberships", + ["org_id", "operator_id"], + unique=True, + ) + + # ── Organization invites ───────────────────────────────────────────────── + op.create_table( + "org_invites", + sa.Column("invite_id", sa.String(64), primary_key=True), + sa.Column( + "org_id", + sa.String(64), + sa.ForeignKey("organizations.org_id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("invited_by", sa.String(128), nullable=False), + sa.Column("invite_token", sa.String(128), nullable=False, unique=True), + sa.Column("role", sa.String(32), nullable=False, server_default="operator"), + sa.Column("status", sa.String(32), nullable=False, server_default="pending"), + sa.Column("accepted_by", sa.String(128), nullable=True), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_org_invites_org_id", "org_invites", ["org_id"]) + op.create_index("ix_org_invites_token", "org_invites", ["invite_token"], unique=True) + op.create_index("ix_org_invites_status", "org_invites", ["status"]) + + # ── Trust links ────────────────────────────────────────────────────────── + op.create_table( + "trust_links", + sa.Column("link_id", sa.String(64), primary_key=True), + sa.Column( + "org_id", + sa.String(64), + sa.ForeignKey("organizations.org_id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("node_a_id", sa.String(64), nullable=False), + sa.Column("node_b_id", sa.String(64), nullable=False), + sa.Column("status", sa.String(32), nullable=False, server_default="active"), + sa.Column("created_by", sa.String(128), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_trust_links_org_id", "trust_links", ["org_id"]) + op.create_index("ix_trust_links_node_a", "trust_links", ["node_a_id"]) + op.create_index("ix_trust_links_node_b", "trust_links", ["node_b_id"]) + + # ── Add org_id to existing tables ──────────────────────────────────────── + op.add_column("tasks", sa.Column("org_id", sa.String(64), nullable=True)) + op.add_column("audit_events", sa.Column("org_id", sa.String(64), nullable=True)) + + +def downgrade() -> None: + op.drop_column("audit_events", "org_id") + op.drop_column("tasks", "org_id") + op.drop_table("trust_links") + op.drop_table("org_invites") + op.drop_table("org_memberships") + op.drop_table("organizations") diff --git a/apps/api/app/auth.py b/apps/api/app/auth.py index 87f4c5c..e6bd9a1 100644 --- a/apps/api/app/auth.py +++ b/apps/api/app/auth.py @@ -1,45 +1,39 @@ """ -AgentLink API — authentication +AgentLink API — authentication and RBAC -Provides two independent auth mechanisms: +Provides three independent auth mechanisms: 1. Node auth (JWT) - JWT signed with HS256, sub=node_id, type="node_session" - Issued at enrollment; used for heartbeat, claim, and result endpoints. - Dependency: require_node_auth → returns verified node_id -2. Operator auth (JWT session) [M7, M21] +2. Operator auth (JWT session) [M7, M21, Phase 4] - Operator calls POST /operator/login with their operator_id + api_key. - - API validates api_key against OPERATOR_API_KEY env var, then issues a - short-lived JWT (sub=operator_id, type="operator_session", jti=). - - Subsequent requests send: Authorization: Bearer - - Dependency: require_operator_auth → decodes JWT, checks denylist, returns operator_id. + - API validates api_key, then issues a short-lived JWT with: + sub=operator_id, type="operator_session", jti=, + org_id=, role= + - Dependency: require_operator_auth → decodes JWT, checks denylist, returns OperatorContext - Token expiry controlled by OPERATOR_TOKEN_EXPIRE_HOURS (default: 8h). - - Refresh: POST /operator/refresh → new token with fresh expiry (M21) - - Logout: POST /operator/logout → adds jti to in-memory denylist (M21) - - Denylist model (M21): - - Stored in app.state.token_denylist: dict[str, datetime] - mapping jti → token expiry. - - Entries are pruned lazily on each auth check (expired entries are removed). - - This is intentionally simple and in-process; replace with Redis for - multi-instance deployments. - - Tokens issued before M21 (no jti claim) are accepted but cannot be revoked. - - The denylist is cleared on API restart (acceptable: tokens expire in 8h). + +3. RBAC permission checking (Phase 4) + - require_role(min_role) → dependency that checks the operator's role + - Roles: owner > admin > operator > viewer + - Scoped per organization via org_id claim in JWT Conceptual separation: - Node auth identifies a specific machine enrolled in the network. - Operator auth identifies a human taking control-plane actions. - They use different token type claims to prevent token reuse across roles. + - RBAC checks ensure the operator has sufficient privileges for the action. TODO (post-MVP hardening): - Switch node auth to RS256 (RSA) or EdDSA once node key signing lands. - Add node token revocation (store revoked jti claims in Redis). - - Shorten node token expiry and implement refresh for production. - Move operator denylist to Redis for multi-instance support. """ import uuid +from dataclasses import dataclass from datetime import datetime, timedelta, timezone from typing import Annotated @@ -49,11 +43,29 @@ from .settings import settings + +# ── RBAC ───────────────────────────────────────────────────────────────────── + +ROLE_HIERARCHY = {"owner": 0, "admin": 1, "operator": 2, "viewer": 3} + + +@dataclass(frozen=True) +class OperatorContext: + """Authenticated operator identity with org/role context.""" + operator_id: str + org_id: str + role: str + + def has_role(self, min_role: str) -> bool: + """True if this operator's role is at least as privileged as min_role.""" + return ROLE_HIERARCHY.get(self.role, 99) <= ROLE_HIERARCHY.get(min_role, 99) + _ALGORITHM = "HS256" _NODE_TOKEN_TYPE = "node_session" _OPERATOR_TOKEN_TYPE = "operator_session" _bearer_scheme = HTTPBearer(auto_error=True) +_bearer_scheme_optional = HTTPBearer(auto_error=False) # ── Node auth ───────────────────────────────────────────────────────────────── @@ -150,12 +162,17 @@ async def heartbeat( # ── Operator auth ───────────────────────────────────────────────────────────── -def create_operator_token(operator_id: str) -> tuple[str, datetime]: +def create_operator_token( + operator_id: str, + org_id: str = "default", + role: str = "owner", +) -> tuple[str, datetime]: """ Issue a signed JWT for an operator session. Called by POST /operator/login and POST /operator/refresh. Each token gets a unique jti claim (UUID4 hex) used for denylist revocation (M21). + Phase 4: includes org_id and role claims for RBAC. Returns: (token_string, expires_at_utc) @@ -168,7 +185,9 @@ def create_operator_token(operator_id: str) -> tuple[str, datetime]: "type": _OPERATOR_TOKEN_TYPE, "iat": now, "exp": expires_at, - "jti": uuid.uuid4().hex, # M21: unique ID for revocation via denylist + "jti": uuid.uuid4().hex, + "org_id": org_id, + "role": role, } token = jwt.encode(payload, settings.api_secret_key, algorithm=_ALGORITHM) @@ -205,11 +224,11 @@ def extract_operator_token_claims(token: str) -> dict | None: def _decode_operator_token( token: str, denylist: dict[str, datetime] | None = None, -) -> str: +) -> OperatorContext: """ Decode and validate an operator session JWT. - Returns the operator_id (sub claim) on success. + Returns an OperatorContext with operator_id, org_id, and role. Raises HTTPException 401 on any validation failure, including if the token's jti is present in the denylist (M21 revocation). @@ -265,17 +284,21 @@ def _decode_operator_token( headers={"WWW-Authenticate": "Bearer"}, ) - return operator_id + # Phase 4: extract org/role claims with backward-compatible defaults + org_id = payload.get("org_id", "default") + role = payload.get("role", "owner") + + return OperatorContext(operator_id=operator_id, org_id=org_id, role=role) def require_operator_auth( request: Request, credentials: Annotated[HTTPAuthorizationCredentials, Depends(_bearer_scheme)], -) -> str: +) -> OperatorContext: """ FastAPI dependency: extract and validate the operator session JWT. - Returns the authenticated operator_id. + Returns an OperatorContext with operator_id, org_id, and role. Raises 401 if the token is absent, malformed, expired, wrong type, or has been revoked via POST /operator/logout (M21 denylist check). @@ -286,12 +309,81 @@ def require_operator_auth( @router.post("/{task_id}/approve") async def approve_task( task_id: str, - auth_operator: str = Depends(require_operator_auth), + auth: OperatorContext = Depends(require_operator_auth), ): ... """ - # Access the in-process denylist from app state (M21). - # Falls back to empty dict if not initialised (e.g. during unit tests that - # bypass the lifespan; those tests should set app.state.token_denylist = {}). denylist: dict[str, datetime] = getattr(request.app.state, "token_denylist", {}) return _decode_operator_token(credentials.credentials, denylist) + + +def optional_operator_auth( + request: Request, + credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer_scheme_optional)] = None, +) -> OperatorContext | None: + """ + FastAPI dependency: optionally extract operator context from Bearer JWT. + Returns None if no token is provided (does not 401). + Returns OperatorContext if a valid token is provided. + Raises 401 only if a token IS provided but is invalid/expired/revoked. + """ + if credentials is None: + return None + denylist: dict[str, datetime] = getattr(request.app.state, "token_denylist", {}) + return _decode_operator_token(credentials.credentials, denylist) + + +def require_auth_or_demo( + request: Request, + credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer_scheme_optional)] = None, +) -> OperatorContext | None: + """ + FastAPI dependency: require auth when REQUIRE_AUTH_FOR_READS=True (default). + In demo mode (REQUIRE_AUTH_FOR_READS=False), allows unauthenticated access. + + - Production: 401 if no token provided. Returns OperatorContext. + - Demo mode: returns None if no token. If a token is provided, attempts to + decode it as an operator token; returns None on failure (node tokens, etc.). + """ + if credentials is not None: + denylist: dict[str, datetime] = getattr(request.app.state, "token_denylist", {}) + if settings.require_auth_for_reads: + # Production: strict — any token must be a valid operator token + return _decode_operator_token(credentials.credentials, denylist) + else: + # Demo mode: best-effort — try operator decode, ignore failures + try: + return _decode_operator_token(credentials.credentials, denylist) + except HTTPException: + return None + + if settings.require_auth_for_reads: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required. Log in via POST /operator/login.", + headers={"WWW-Authenticate": "Bearer"}, + ) + return None + + +def require_role(min_role: str): + """ + Factory for a FastAPI dependency that enforces a minimum RBAC role. + + Usage: + @router.post("/admin-action") + async def admin_action( + auth: OperatorContext = Depends(require_role("admin")), + ): + ... + """ + def _check( + auth: Annotated[OperatorContext, Depends(require_operator_auth)], + ) -> OperatorContext: + if not auth.has_role(min_role): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Requires at least '{min_role}' role. Your role: '{auth.role}'.", + ) + return auth + return _check diff --git a/apps/api/app/db/models.py b/apps/api/app/db/models.py index 4dd3a95..f579006 100644 --- a/apps/api/app/db/models.py +++ b/apps/api/app/db/models.py @@ -16,10 +16,13 @@ avoid shadowing SQLAlchemy's class-level `Base.metadata` attribute. The database column is still named "metadata". -TODO (Phase 7+): - - Add org_memberships and trust_links tables for peer-to-peer linking - - Add policies table for persistent rule sets - - ArtifactRecord (M13): stores artifact metadata and either the inline +Multi-user models (Phase 4): + - OrganizationRecord: shared workspace scoping all resources + - OrgMembershipRecord: ties operators to organizations with RBAC roles + - OrgInviteRecord: short-lived invite tokens for joining an org + - TrustLinkRecord: peer-to-peer trust relationships between nodes + + ArtifactRecord (M13): stores artifact metadata and either the inline payload or a filesystem path depending on storage_mode. """ @@ -130,6 +133,7 @@ class TaskRecord(Base): __tablename__ = "tasks" task_id: Mapped[str] = mapped_column(String(128), primary_key=True) + org_id: Mapped[str | None] = mapped_column(String(64), nullable=True) task_type: Mapped[str] = mapped_column(String(128), nullable=False) requester_node_id: Mapped[str] = mapped_column(String(64), nullable=False) requester_user_id: Mapped[str] = mapped_column(String(128), nullable=False) @@ -226,6 +230,102 @@ def __repr__(self) -> str: ) +class OrganizationRecord(Base): + """ + Shared workspace that scopes all resources (nodes, tasks, artifacts, audit). + + Every resource belongs to exactly one organization. The default bootstrap + creates a single 'default' organization for backward compatibility. + """ + + __tablename__ = "organizations" + + org_id: Mapped[str] = mapped_column(String(64), primary_key=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + slug: Mapped[str] = mapped_column(String(128), nullable=False, unique=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + def __repr__(self) -> str: + return f"" + + +class OrgMembershipRecord(Base): + """ + Ties an operator to an organization with a specific RBAC role. + + Roles: owner, admin, operator, viewer. + An operator can belong to multiple organizations. + """ + + __tablename__ = "org_memberships" + + membership_id: Mapped[str] = mapped_column(String(64), primary_key=True) + org_id: Mapped[str] = mapped_column( + String(64), ForeignKey("organizations.org_id", ondelete="CASCADE"), nullable=False + ) + operator_id: Mapped[str] = mapped_column(String(128), nullable=False) + role: Mapped[str] = mapped_column(String(32), nullable=False, default="operator") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + def __repr__(self) -> str: + return f"" + + +class OrgInviteRecord(Base): + """ + Short-lived invitation token for joining an organization. + + Lifecycle: pending → accepted | revoked | expired + The invite_token is a cryptographically random string generated server-side. + """ + + __tablename__ = "org_invites" + + invite_id: Mapped[str] = mapped_column(String(64), primary_key=True) + org_id: Mapped[str] = mapped_column( + String(64), ForeignKey("organizations.org_id", ondelete="CASCADE"), nullable=False + ) + invited_by: Mapped[str] = mapped_column(String(128), nullable=False) + invite_token: Mapped[str] = mapped_column(String(128), nullable=False, unique=True) + role: Mapped[str] = mapped_column(String(32), nullable=False, default="operator") + status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending") + accepted_by: Mapped[str | None] = mapped_column(String(128), nullable=True) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + def __repr__(self) -> str: + return f"" + + +class TrustLinkRecord(Base): + """ + Peer-to-peer trust relationship between two nodes. + + Establishes that node_a trusts node_b for cross-node task routing. + Trust links are directional: node_a → node_b does not imply node_b → node_a. + """ + + __tablename__ = "trust_links" + + link_id: Mapped[str] = mapped_column(String(64), primary_key=True) + org_id: Mapped[str] = mapped_column( + String(64), ForeignKey("organizations.org_id", ondelete="CASCADE"), nullable=False + ) + node_a_id: Mapped[str] = mapped_column(String(64), nullable=False) + node_b_id: Mapped[str] = mapped_column(String(64), nullable=False) + status: Mapped[str] = mapped_column(String(32), nullable=False, default="active") + created_by: Mapped[str] = mapped_column(String(128), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + def __repr__(self) -> str: + return f"" + + class AuditEventRecord(Base): """ Immutable record of a meaningful system action. @@ -238,6 +338,7 @@ class AuditEventRecord(Base): __tablename__ = "audit_events" event_id: Mapped[str] = mapped_column(String(128), primary_key=True) + org_id: Mapped[str | None] = mapped_column(String(64), nullable=True) action: Mapped[str] = mapped_column(String(64), nullable=False) node_id: Mapped[str] = mapped_column(String(64), nullable=False, default="control_plane") actor_id: Mapped[str] = mapped_column(String(128), nullable=False) diff --git a/apps/api/app/db/repository.py b/apps/api/app/db/repository.py index 54afdce..53a9d72 100644 --- a/apps/api/app/db/repository.py +++ b/apps/api/app/db/repository.py @@ -29,12 +29,16 @@ from sqlalchemy import and_, case, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession -from app.models.enums import NodeStatus, AgentStatus, PrivacyClass, RuntimeType, ArtifactType, AuditAction, TaskStatus +from app.models.enums import NodeStatus, AgentStatus, PrivacyClass, RuntimeType, ArtifactType, AuditAction, TaskStatus, InviteStatus from app.models.node import AdapterStatusSummary, Capability, CapabilitySummary, Node, NodeActivityRecentTask from app.models.agent import Agent, AgentCard from app.models.audit import AuditEvent, AuditEventPage from app.models.task import Artifact, ArtifactRef, PolicyDecision, Task, TaskError, TaskPage, TaskPayload -from app.db.models import AgentRecord, ArtifactRecord, AuditEventRecord, NodeRecord, TaskRecord +from app.models.org import Organization, OrgMembership, OrgInvite, TrustLink +from app.db.models import ( + AgentRecord, ArtifactRecord, AuditEventRecord, NodeRecord, TaskRecord, + OrganizationRecord, OrgMembershipRecord, OrgInviteRecord, TrustLinkRecord, +) from app.services.artifact_store import ArtifactStore, INLINE, FILE, EXPIRED from app.settings import settings @@ -251,11 +255,14 @@ async def exists(self, node_id: str) -> bool: record = await self._session.get(NodeRecord, node_id) return record is not None - async def list_all(self) -> list[Node]: - """Return all enrolled nodes ordered by creation time.""" - result = await self._session.execute( - select(NodeRecord).order_by(NodeRecord.created_at) - ) + async def list_all(self, org_id: str | None = None) -> list[Node]: + """Return enrolled nodes ordered by creation time. Optionally filter by org.""" + stmt = select(NodeRecord).order_by(NodeRecord.created_at) + if org_id is not None: + stmt = stmt.where( + or_(NodeRecord.org_id == org_id, NodeRecord.org_id.is_(None)) + ) + result = await self._session.execute(stmt) return [_record_to_node(r) for r in result.scalars().all()] async def mark_stale_offline(self, cutoff: datetime) -> int: @@ -472,6 +479,7 @@ def _record_to_task(r: TaskRecord) -> Task: return Task( task_id=r.task_id, + org_id=r.org_id, task_type=r.task_type, requester_node_id=r.requester_node_id, requester_user_id=r.requester_user_id, @@ -623,6 +631,7 @@ def _apply_task_filters( failure_class: str | None, requester_user_id: str | None, requester_node_id: str | None = None, + org_id: str | None = None, ): """ Apply shared WHERE predicates to a task SELECT or COUNT statement (M31/M44). @@ -631,19 +640,13 @@ def _apply_task_filters( None-valued parameters are skipped — omitting a filter returns all rows for that dimension without changing the meaning of the other filters. - Called by both count_tasks() and list_tasks_page() with identical - arguments so the COUNT and SELECT queries see the same predicate set, - keeping the TaskPage.total field accurate. - - Filter mapping: - node_id → target_node_id column (legacy alias resolved by router) - status → TaskStatus enum value string - task_type → task_type string (exact match, case-sensitive) - retry_origin → "automatic" | "manual" | None - failure_class → "transient" | "terminal" | None - requester_user_id → requester_user_id string - requester_node_id → requester_node_id string + Phase 4: org_id filter scopes tasks to the authenticated operator's org. + Tasks with null org_id are included for backward compatibility. """ + if org_id: + stmt = stmt.where( + or_(TaskRecord.org_id == org_id, TaskRecord.org_id.is_(None)) + ) if node_id: stmt = stmt.where(TaskRecord.target_node_id == node_id) if status: @@ -669,20 +672,16 @@ async def count_tasks( failure_class: str | None = None, requester_user_id: str | None = None, requester_node_id: str | None = None, + org_id: str | None = None, ) -> int: """ Return the total number of tasks matching the given filters (M31/M44). - - Issues a single COUNT(*) query with the same predicate set as - list_tasks_page() — called first within list_tasks_page() to populate - TaskPage.total before the SELECT page query runs. The total reflects - the filtered universe, not just the current page size, so the web UI - can compute correct page counts and "Showing X of Y total" text. + Phase 4: org_id scopes to the operator's organization. """ stmt = select(func.count()).select_from(TaskRecord) stmt = self._apply_task_filters( stmt, node_id, status, task_type, retry_origin, failure_class, - requester_user_id, requester_node_id, + requester_user_id, requester_node_id, org_id=org_id, ) result = await self._session.execute(stmt) return result.scalar() or 0 @@ -698,6 +697,7 @@ async def list_tasks_page( requester_node_id: str | None = None, limit: int = 50, offset: int = 0, + org_id: str | None = None, ) -> TaskPage: """ Return a TaskPage: one page of tasks plus the total filtered count (M31/M44). @@ -729,6 +729,7 @@ async def list_tasks_page( failure_class=failure_class, requester_user_id=requester_user_id, requester_node_id=requester_node_id, + org_id=org_id, ) stmt = ( select(TaskRecord) @@ -738,7 +739,7 @@ async def list_tasks_page( ) stmt = self._apply_task_filters( stmt, node_id, status, task_type, retry_origin, failure_class, - requester_user_id, requester_node_id, + requester_user_id, requester_node_id, org_id=org_id, ) result = await self._session.execute(stmt) tasks = [_record_to_task(r) for r in result.scalars().all()] @@ -973,16 +974,21 @@ async def claim(self, task_id: str, node_id: str) -> Task | None: await self._session.refresh(record) return _record_to_task(record) - async def list_pending_approval(self) -> list[Task]: + async def list_pending_approval(self, org_id: str | None = None) -> list[Task]: """ Return all tasks in pending_approval status, ordered by priority then age. - Used by the approval queue UI and admin endpoints. + Phase 4: optionally scoped by org_id. """ - result = await self._session.execute( + stmt = ( select(TaskRecord) .where(TaskRecord.status == TaskStatus.pending_approval.value) .order_by(TaskRecord.priority.desc(), TaskRecord.created_at) ) + if org_id: + stmt = stmt.where( + or_(TaskRecord.org_id == org_id, TaskRecord.org_id.is_(None)) + ) + result = await self._session.execute(stmt) return [_record_to_task(r) for r in result.scalars().all()] async def approve(self, task_id: str) -> Task | None: @@ -1346,26 +1352,16 @@ def _apply_audit_filters( resource_type: str | None, from_dt: datetime | None, to_dt: datetime | None, + org_id: str | None = None, ): """ Apply shared WHERE predicates to an audit SELECT or COUNT statement (M30). - - All active filters are AND-composed exact-match conditions except for - the time range (from_dt / to_dt use >= and <=). None-valued parameters - are skipped, narrowing the result only on the active dimensions. - - Called by both count_events() and list_events_page() with identical - arguments to keep AuditEventPage.total aligned with the page rows. - - Filter mapping: - resource_id → resource_id string (task_id, node_id, agent_id, etc.) - actor_id → actor_id string (operator session user, node_id, etc.) - action → AuditAction enum value string - actor_type → "node" | "operator" | "user" | "system" - resource_type → "task" | "node" | "agent" (M28 validation) - from_dt → occurred_at >= from_dt (inclusive lower bound) - to_dt → occurred_at <= to_dt (inclusive upper bound) + Phase 4: org_id scopes events to the operator's org (null org_id included for compat). """ + if org_id: + stmt = stmt.where( + or_(AuditEventRecord.org_id == org_id, AuditEventRecord.org_id.is_(None)) + ) if resource_id: stmt = stmt.where(AuditEventRecord.resource_id == resource_id) if actor_id: @@ -1391,6 +1387,7 @@ async def count_events( resource_type: str | None = None, from_dt: datetime | None = None, to_dt: datetime | None = None, + org_id: str | None = None, ) -> int: """ Return the total number of audit events matching the given filters. @@ -1401,7 +1398,7 @@ async def count_events( """ stmt = select(func.count()).select_from(AuditEventRecord) stmt = self._apply_audit_filters( - stmt, resource_id, actor_id, action, actor_type, resource_type, from_dt, to_dt + stmt, resource_id, actor_id, action, actor_type, resource_type, from_dt, to_dt, org_id=org_id ) result = await self._session.execute(stmt) return result.scalar_one() @@ -1418,6 +1415,7 @@ async def list_events_page( limit: int = 50, offset: int = 0, cursor: str | None = None, + org_id: str | None = None, ) -> AuditEventPage: """ Return an AuditEventPage: one page of events plus the total filtered count (M30/M78). @@ -1472,6 +1470,7 @@ async def list_events_page( resource_type=resource_type, from_dt=from_dt, to_dt=to_dt, + org_id=org_id, ) if cursor is not None: @@ -1499,7 +1498,7 @@ async def list_events_page( .limit(limit + 1) ) stmt = self._apply_audit_filters( - stmt, resource_id, actor_id, action, actor_type, resource_type, from_dt, to_dt + stmt, resource_id, actor_id, action, actor_type, resource_type, from_dt, to_dt, org_id=org_id ) result = await self._session.execute(stmt) rows = result.scalars().all() @@ -1706,3 +1705,313 @@ async def expire_artifacts( await self._session.commit() return len(records) + + +# ── Organization repository ───────────────────────────────────────────────── + + +class OrgRepository: + """Database operations for organizations, memberships, invites, and trust links.""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + # ── Organizations ──────────────────────────────────────────────────────── + + async def ensure_default_org(self) -> Organization: + """Create the default organization if it doesn't exist. Returns it.""" + result = await self._session.execute( + select(OrganizationRecord).where(OrganizationRecord.org_id == "default") + ) + record = result.scalar_one_or_none() + if record: + return self._record_to_org(record) + + now = datetime.now(timezone.utc) + record = OrganizationRecord( + org_id="default", + name="Default Workspace", + slug="default", + description="Default organization created on first login.", + created_at=now, + updated_at=now, + ) + self._session.add(record) + await self._session.commit() + await self._session.refresh(record) + return self._record_to_org(record) + + async def create_org(self, org_id: str, name: str, slug: str, description: str | None = None) -> Organization: + now = datetime.now(timezone.utc) + record = OrganizationRecord( + org_id=org_id, + name=name, + slug=slug, + description=description, + created_at=now, + updated_at=now, + ) + self._session.add(record) + await self._session.commit() + await self._session.refresh(record) + return self._record_to_org(record) + + async def get_org(self, org_id: str) -> Organization | None: + result = await self._session.execute( + select(OrganizationRecord).where(OrganizationRecord.org_id == org_id) + ) + record = result.scalar_one_or_none() + return self._record_to_org(record) if record else None + + async def list_orgs(self) -> list[Organization]: + result = await self._session.execute( + select(OrganizationRecord).order_by(OrganizationRecord.created_at) + ) + return [self._record_to_org(r) for r in result.scalars().all()] + + # ── Memberships ────────────────────────────────────────────────────────── + + async def add_member(self, org_id: str, operator_id: str, role: str = "operator") -> OrgMembership: + import uuid + now = datetime.now(timezone.utc) + record = OrgMembershipRecord( + membership_id=uuid.uuid4().hex[:16], + org_id=org_id, + operator_id=operator_id, + role=role, + created_at=now, + updated_at=now, + ) + self._session.add(record) + await self._session.commit() + await self._session.refresh(record) + return self._record_to_membership(record) + + async def get_membership(self, org_id: str, operator_id: str) -> OrgMembership | None: + result = await self._session.execute( + select(OrgMembershipRecord).where( + OrgMembershipRecord.org_id == org_id, + OrgMembershipRecord.operator_id == operator_id, + ) + ) + record = result.scalar_one_or_none() + return self._record_to_membership(record) if record else None + + async def get_first_membership(self, operator_id: str) -> OrgMembership | None: + """Get the first org membership for an operator (for login default org).""" + result = await self._session.execute( + select(OrgMembershipRecord) + .where(OrgMembershipRecord.operator_id == operator_id) + .order_by(OrgMembershipRecord.created_at) + .limit(1) + ) + record = result.scalar_one_or_none() + return self._record_to_membership(record) if record else None + + async def list_members(self, org_id: str) -> list[OrgMembership]: + result = await self._session.execute( + select(OrgMembershipRecord) + .where(OrgMembershipRecord.org_id == org_id) + .order_by(OrgMembershipRecord.created_at) + ) + return [self._record_to_membership(r) for r in result.scalars().all()] + + async def update_member_role(self, org_id: str, operator_id: str, new_role: str) -> OrgMembership | None: + result = await self._session.execute( + select(OrgMembershipRecord).where( + OrgMembershipRecord.org_id == org_id, + OrgMembershipRecord.operator_id == operator_id, + ) + ) + record = result.scalar_one_or_none() + if not record: + return None + record.role = new_role + record.updated_at = datetime.now(timezone.utc) + await self._session.commit() + await self._session.refresh(record) + return self._record_to_membership(record) + + async def remove_member(self, org_id: str, operator_id: str) -> bool: + result = await self._session.execute( + select(OrgMembershipRecord).where( + OrgMembershipRecord.org_id == org_id, + OrgMembershipRecord.operator_id == operator_id, + ) + ) + record = result.scalar_one_or_none() + if not record: + return False + await self._session.delete(record) + await self._session.commit() + return True + + # ── Invites ────────────────────────────────────────────────────────────── + + async def create_invite( + self, invite_id: str, org_id: str, invited_by: str, invite_token: str, + role: str, expires_at: datetime, + ) -> OrgInvite: + now = datetime.now(timezone.utc) + record = OrgInviteRecord( + invite_id=invite_id, + org_id=org_id, + invited_by=invited_by, + invite_token=invite_token, + role=role, + status=InviteStatus.pending.value, + expires_at=expires_at, + created_at=now, + updated_at=now, + ) + self._session.add(record) + await self._session.commit() + await self._session.refresh(record) + return self._record_to_invite(record) + + async def get_invite_by_token(self, invite_token: str) -> OrgInvite | None: + result = await self._session.execute( + select(OrgInviteRecord).where(OrgInviteRecord.invite_token == invite_token) + ) + record = result.scalar_one_or_none() + return self._record_to_invite(record) if record else None + + async def accept_invite(self, invite_token: str, operator_id: str) -> OrgInvite | None: + result = await self._session.execute( + select(OrgInviteRecord).where(OrgInviteRecord.invite_token == invite_token) + ) + record = result.scalar_one_or_none() + if not record: + return None + record.status = InviteStatus.accepted.value + record.accepted_by = operator_id + record.updated_at = datetime.now(timezone.utc) + await self._session.commit() + await self._session.refresh(record) + return self._record_to_invite(record) + + async def revoke_invite(self, invite_id: str) -> OrgInvite | None: + result = await self._session.execute( + select(OrgInviteRecord).where(OrgInviteRecord.invite_id == invite_id) + ) + record = result.scalar_one_or_none() + if not record: + return None + record.status = InviteStatus.revoked.value + record.updated_at = datetime.now(timezone.utc) + await self._session.commit() + await self._session.refresh(record) + return self._record_to_invite(record) + + async def list_invites(self, org_id: str) -> list[OrgInvite]: + result = await self._session.execute( + select(OrgInviteRecord) + .where(OrgInviteRecord.org_id == org_id) + .order_by(OrgInviteRecord.created_at.desc()) + ) + return [self._record_to_invite(r) for r in result.scalars().all()] + + # ── Trust Links ────────────────────────────────────────────────────────── + + async def has_active_trust_link(self, org_id: str, node_a_id: str, node_b_id: str) -> bool: + """Check if an active trust link already exists between the two nodes in this org.""" + result = await self._session.execute( + select(TrustLinkRecord).where( + TrustLinkRecord.org_id == org_id, + TrustLinkRecord.node_a_id == node_a_id, + TrustLinkRecord.node_b_id == node_b_id, + TrustLinkRecord.status == "active", + ) + ) + return result.scalar_one_or_none() is not None + + async def create_trust_link( + self, link_id: str, org_id: str, node_a_id: str, node_b_id: str, created_by: str, + ) -> TrustLink: + now = datetime.now(timezone.utc) + record = TrustLinkRecord( + link_id=link_id, + org_id=org_id, + node_a_id=node_a_id, + node_b_id=node_b_id, + status="active", + created_by=created_by, + created_at=now, + updated_at=now, + ) + self._session.add(record) + await self._session.commit() + await self._session.refresh(record) + return self._record_to_trust_link(record) + + async def list_trust_links(self, org_id: str) -> list[TrustLink]: + result = await self._session.execute( + select(TrustLinkRecord) + .where(TrustLinkRecord.org_id == org_id) + .order_by(TrustLinkRecord.created_at.desc()) + ) + return [self._record_to_trust_link(r) for r in result.scalars().all()] + + async def revoke_trust_link(self, link_id: str) -> TrustLink | None: + result = await self._session.execute( + select(TrustLinkRecord).where(TrustLinkRecord.link_id == link_id) + ) + record = result.scalar_one_or_none() + if not record: + return None + record.status = "revoked" + record.updated_at = datetime.now(timezone.utc) + await self._session.commit() + await self._session.refresh(record) + return self._record_to_trust_link(record) + + # ── Conversion helpers ─────────────────────────────────────────────────── + + @staticmethod + def _record_to_org(r: OrganizationRecord) -> Organization: + return Organization( + org_id=r.org_id, + name=r.name, + slug=r.slug, + description=r.description, + created_at=r.created_at, + updated_at=r.updated_at, + ) + + @staticmethod + def _record_to_membership(r: OrgMembershipRecord) -> OrgMembership: + return OrgMembership( + membership_id=r.membership_id, + org_id=r.org_id, + operator_id=r.operator_id, + role=r.role, + created_at=r.created_at, + updated_at=r.updated_at, + ) + + @staticmethod + def _record_to_invite(r: OrgInviteRecord) -> OrgInvite: + return OrgInvite( + invite_id=r.invite_id, + org_id=r.org_id, + invited_by=r.invited_by, + role=r.role, + status=r.status, + expires_at=r.expires_at, + accepted_by=r.accepted_by, + created_at=r.created_at, + updated_at=r.updated_at, + ) + + @staticmethod + def _record_to_trust_link(r: TrustLinkRecord) -> TrustLink: + return TrustLink( + link_id=r.link_id, + org_id=r.org_id, + node_a_id=r.node_a_id, + node_b_id=r.node_b_id, + status=r.status, + created_by=r.created_by, + created_at=r.created_at, + updated_at=r.updated_at, + ) diff --git a/apps/api/app/models/audit.py b/apps/api/app/models/audit.py index b6b43bb..9f6bd99 100644 --- a/apps/api/app/models/audit.py +++ b/apps/api/app/models/audit.py @@ -37,11 +37,15 @@ class AuditEvent(BaseModel): # M27: constrained to known actor types. actor_type: Literal["node", "user", "operator", "system"] resource_id: str | None = None - # M28: constrained to currently emitted resource types. - # "task" — task lifecycle events (created, claimed, completed, failed, retried, cancelled, etc.) - # "node" — node and agent inventory events (linked, connected, disconnected, agent_exposed) - # None — system events with no associated resource (reserved / legacy compatibility) - resource_type: Literal["task", "node"] | None = None + # M28/Phase 4: constrained to known resource types. + # "task" — task lifecycle events + # "node" — node/agent inventory events + # "org" — organization lifecycle events + # "membership" — membership changes + # "invite" — invite lifecycle events + # "trust_link" — trust link events + # None — system events with no associated resource + resource_type: Literal["task", "node", "org", "membership", "invite", "trust_link"] | None = None # M27: constrained to known outcome values; mirrors TypeScript union. outcome: Literal["success", "denied", "error"] description: str | None = None diff --git a/apps/api/app/models/enums.py b/apps/api/app/models/enums.py index 6c5b8ff..2324468 100644 --- a/apps/api/app/models/enums.py +++ b/apps/api/app/models/enums.py @@ -101,6 +101,36 @@ class AuditAction(str, Enum): node_disconnected = "node_disconnected" invite_sent = "invite_sent" invite_accepted = "invite_accepted" + invite_revoked = "invite_revoked" + invite_failed = "invite_failed" + member_added = "member_added" + member_removed = "member_removed" + member_role_changed = "member_role_changed" + org_created = "org_created" + trust_link_created = "trust_link_created" + trust_link_revoked = "trust_link_revoked" + + +class OrgRole(str, Enum): + """Organization membership roles — ordered by privilege level.""" + owner = "owner" + admin = "admin" + operator = "operator" + viewer = "viewer" + + +class InviteStatus(str, Enum): + """Lifecycle states for organization invitations.""" + pending = "pending" + accepted = "accepted" + revoked = "revoked" + expired = "expired" + + +class TrustLinkStatus(str, Enum): + """Lifecycle states for peer trust links.""" + active = "active" + revoked = "revoked" class PrivacyClass(str, Enum): diff --git a/apps/api/app/models/org.py b/apps/api/app/models/org.py new file mode 100644 index 0000000..de2c9ea --- /dev/null +++ b/apps/api/app/models/org.py @@ -0,0 +1,105 @@ +""" +AgentLink API — organization / membership / invite models + +Pydantic models for the multi-user authorization layer. +Mirrors the protocol types in packages/protocol/src/org.ts. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field + + +# ── Organization ───────────────────────────────────────────────────────────── + + +class Organization(BaseModel): + org_id: str + name: str + slug: str + description: Optional[str] = None + created_at: datetime + updated_at: datetime + + +class CreateOrganizationRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=255) + slug: str = Field(..., min_length=1, max_length=128, pattern=r"^[a-z0-9][a-z0-9\-]*[a-z0-9]$") + description: Optional[str] = Field(None, max_length=1000) + + +# ── Membership ─────────────────────────────────────────────────────────────── + + +class OrgMembership(BaseModel): + membership_id: str + org_id: str + operator_id: str + role: str # owner | admin | operator | viewer + created_at: datetime + updated_at: datetime + + +class AddMemberRequest(BaseModel): + operator_id: str = Field(..., min_length=1, max_length=128) + role: str = Field("operator", pattern=r"^(owner|admin|operator|viewer)$") + + +class UpdateMemberRoleRequest(BaseModel): + role: str = Field(..., pattern=r"^(owner|admin|operator|viewer)$") + + +# ── Invite ─────────────────────────────────────────────────────────────────── + + +class OrgInvite(BaseModel): + invite_id: str + org_id: str + invited_by: str + role: str + status: str # pending | accepted | revoked | expired + expires_at: datetime + accepted_by: Optional[str] = None + created_at: datetime + updated_at: datetime + + +class CreateInviteRequest(BaseModel): + role: str = Field("operator", pattern=r"^(owner|admin|operator|viewer)$") + expires_hours: int = Field(48, ge=1, le=720) # 1h to 30d + + +class AcceptInviteRequest(BaseModel): + invite_token: str = Field(..., min_length=1, max_length=128) + operator_id: str = Field(..., min_length=1, max_length=128) + + +class InviteResponse(BaseModel): + """Returned when creating an invite — includes the token (shown once).""" + invite_id: str + org_id: str + invite_token: str + role: str + expires_at: datetime + + +# ── Trust Link ─────────────────────────────────────────────────────────────── + + +class TrustLink(BaseModel): + link_id: str + org_id: str + node_a_id: str + node_b_id: str + status: str # active | revoked + created_by: str + created_at: datetime + updated_at: datetime + + +class CreateTrustLinkRequest(BaseModel): + node_a_id: str = Field(..., min_length=1, max_length=64) + node_b_id: str = Field(..., min_length=1, max_length=64) diff --git a/apps/api/app/models/task.py b/apps/api/app/models/task.py index 5f2c53d..6b08460 100644 --- a/apps/api/app/models/task.py +++ b/apps/api/app/models/task.py @@ -47,6 +47,7 @@ class PolicyDecision(BaseModel): class Task(BaseModel): task_id: str + org_id: str | None = None task_type: str requester_node_id: str requester_user_id: str diff --git a/apps/api/app/routers/artifacts.py b/apps/api/app/routers/artifacts.py index d1a1d30..124880d 100644 --- a/apps/api/app/routers/artifacts.py +++ b/apps/api/app/routers/artifacts.py @@ -32,8 +32,9 @@ from fastapi.responses import Response from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import OperatorContext, require_auth_or_demo from app.database import get_db -from app.db.repository import ArtifactRepository +from app.db.repository import ArtifactRepository, TaskRepository from app.models.task import Artifact from app.services.artifact_store import ArtifactStore, get_artifact_store, FILE, EXPIRED @@ -46,6 +47,12 @@ def get_artifact_repo( return ArtifactRepository(session) +def get_task_repo( + session: Annotated[AsyncSession, Depends(get_db)], +) -> TaskRepository: + return TaskRepository(session) + + # ── Metadata ────────────────────────────────────────────────────────────────── @@ -53,6 +60,8 @@ def get_artifact_repo( async def get_artifact( artifact_id: str, artifact_repo: Annotated[ArtifactRepository, Depends(get_artifact_repo)], + task_repo: Annotated[TaskRepository, Depends(get_task_repo)], + auth: Annotated[OperatorContext | None, Depends(require_auth_or_demo)] = None, ) -> Artifact: """ Return full artifact metadata for a given artifact ID. @@ -69,6 +78,11 @@ async def get_artifact( artifact = await artifact_repo.get_artifact_model(artifact_id) if artifact is None: raise HTTPException(status_code=404, detail=f"Artifact {artifact_id!r} not found") + # Org scoping through parent task + if auth and artifact.task_id: + task = await task_repo.get(artifact.task_id) + if task and task.org_id and task.org_id != auth.org_id: + raise HTTPException(status_code=404, detail=f"Artifact {artifact_id!r} not found") return artifact @@ -79,7 +93,9 @@ async def get_artifact( async def get_artifact_payload( artifact_id: str, artifact_repo: Annotated[ArtifactRepository, Depends(get_artifact_repo)], + task_repo: Annotated[TaskRepository, Depends(get_task_repo)], artifact_store: Annotated[ArtifactStore, Depends(get_artifact_store)], + auth: Annotated[OperatorContext | None, Depends(require_auth_or_demo)] = None, ) -> Response: """ Retrieve the raw payload for an artifact. diff --git a/apps/api/app/routers/audit.py b/apps/api/app/routers/audit.py index a0245f2..4b577e1 100644 --- a/apps/api/app/routers/audit.py +++ b/apps/api/app/routers/audit.py @@ -65,7 +65,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession -from app.auth import require_operator_auth +from app.auth import OperatorContext, require_operator_auth from app.database import get_db from app.db.repository import AuditRepository from app.models.audit import AuditEventPage @@ -82,13 +82,13 @@ def get_audit_repo( @router.get("/events", response_model=AuditEventPage) async def list_audit_events( - auth_operator: Annotated[str, Depends(require_operator_auth)], + auth_operator: Annotated[OperatorContext, Depends(require_operator_auth)], resource_id: str | None = None, actor_id: str | None = None, action: AuditAction | None = None, actor_type: Literal["node", "user", "operator", "system"] | None = None, - # M28: constrained; FastAPI returns 422 for any value not in this set. - resource_type: Literal["task", "node"] | None = None, + # M28/Phase 4: constrained; FastAPI returns 422 for any value not in this set. + resource_type: Literal["task", "node", "org", "membership", "invite", "trust_link"] | None = None, from_dt: datetime | None = None, to_dt: datetime | None = None, limit: int = 50, @@ -177,6 +177,7 @@ async def list_audit_events( limit=effective_limit, offset=effective_offset, cursor=effective_cursor, + org_id=auth_operator.org_id, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/apps/api/app/routers/nodes.py b/apps/api/app/routers/nodes.py index 97278a8..4166377 100644 --- a/apps/api/app/routers/nodes.py +++ b/apps/api/app/routers/nodes.py @@ -35,7 +35,7 @@ from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession -from app.auth import require_node_auth, require_operator_auth +from app.auth import OperatorContext, optional_operator_auth, require_auth_or_demo, require_node_auth, require_operator_auth from app.database import get_db from app.db.repository import AgentRepository, AuditRepository, NodeRepository, TaskRepository from app.models.agent import AgentCard, CapabilityPublishAck, CapabilityPublishRequest @@ -338,13 +338,15 @@ async def publish_capabilities( @router.get("", response_model=NodeListResponse) async def list_nodes( repo: Annotated[NodeRepository, Depends(get_node_repo)], + auth_operator: Annotated[OperatorContext | None, Depends(require_auth_or_demo)] = None, ) -> NodeListResponse: """ - List all enrolled nodes, ordered by creation time. - - TODO (Phase 4): Require auth. Filter by visibility policy. + List enrolled nodes, ordered by creation time. + Requires auth unless REQUIRE_AUTH_FOR_READS=false (demo mode). + When authenticated, results are scoped to the operator's org. """ - nodes = await repo.list_all() + org_id = auth_operator.org_id if auth_operator else None + nodes = await repo.list_all(org_id=org_id) return NodeListResponse(nodes=nodes, total=len(nodes)) @@ -361,7 +363,7 @@ class NodeConnectionsResponse(BaseModel): @router.get("/connections", response_model=NodeConnectionsResponse) async def list_connected_nodes( request: Request, - auth_operator: Annotated[str, Depends(require_operator_auth)], + auth_operator: Annotated[OperatorContext, Depends(require_operator_auth)], ) -> NodeConnectionsResponse: """ Return the list of node IDs currently connected via WebSocket. @@ -381,18 +383,17 @@ async def list_connected_nodes( async def get_node( node_id: str, repo: Annotated[NodeRepository, Depends(get_node_repo)], + auth: Annotated[OperatorContext | None, Depends(require_auth_or_demo)] = None, ) -> Node: - """ - Get a specific enrolled node by ID. - - TODO (Phase 4): Require auth. Enforce visibility policy. - """ + """Get a specific enrolled node by ID. Requires auth unless demo mode.""" node = await repo.get(node_id) if node is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Node {node_id!r} not found", ) + if auth and node.org_id and node.org_id != auth.org_id: + raise HTTPException(status_code=404, detail=f"Node {node_id!r} not found") return node @@ -400,20 +401,17 @@ async def get_node( async def get_node_capabilities( node_id: str, repo: Annotated[NodeRepository, Depends(get_node_repo)], + auth: Annotated[OperatorContext | None, Depends(require_auth_or_demo)] = None, ) -> CapabilitySummary: - """ - Return a node's current capability summary. - - Updated whenever the node posts to POST /nodes/{id}/capabilities. - - TODO (Phase 4): Require auth. Enforce visibility policy. - """ + """Return a node's current capability summary. Requires auth unless demo mode.""" node = await repo.get(node_id) if node is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Node {node_id!r} not found", ) + if auth and node.org_id and node.org_id != auth.org_id: + raise HTTPException(status_code=404, detail=f"Node {node_id!r} not found") return node.capabilities @@ -422,6 +420,7 @@ async def list_node_agents( node_id: str, node_repo: Annotated[NodeRepository, Depends(get_node_repo)], agent_repo: Annotated[AgentRepository, Depends(get_agent_repo)], + auth: Annotated[OperatorContext | None, Depends(require_auth_or_demo)] = None, ) -> list[AgentCard]: """ List all agents discovered on a specific node. diff --git a/apps/api/app/routers/operator.py b/apps/api/app/routers/operator.py index 6209171..a377666 100644 --- a/apps/api/app/routers/operator.py +++ b/apps/api/app/routers/operator.py @@ -43,12 +43,16 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession from app.auth import ( + OperatorContext, create_operator_token, extract_operator_token_claims, require_operator_auth, ) +from app.database import get_db +from app.db.repository import OrgRepository from app.settings import settings router = APIRouter() @@ -75,13 +79,18 @@ class OperatorLoginResponse(BaseModel): operator_id: str token: str expires_at: datetime + org_id: str = "default" + role: str = "owner" # ── Endpoints ───────────────────────────────────────────────────────────────── @router.post("/login", response_model=OperatorLoginResponse, status_code=200) -async def operator_login(body: OperatorLoginRequest) -> OperatorLoginResponse: +async def operator_login( + body: OperatorLoginRequest, + session: Annotated[AsyncSession, Depends(get_db)], +) -> OperatorLoginResponse: """ Exchange operator credentials for a short-lived JWT session token. @@ -89,6 +98,9 @@ async def operator_login(body: OperatorLoginRequest) -> OperatorLoginResponse: The `operator_id` is embedded in the token so that subsequent actions (approve, reject, cancel, audit queries) are attributed to a named identity. + Phase 4: The token includes org_id and role from the operator's membership. + If no membership exists, the operator is auto-enrolled in the default org as owner. + Token lifetime: controlled by `OPERATOR_TOKEN_EXPIRE_HOURS` (default: 8h). Returns 401 if the api_key does not match. @@ -100,36 +112,50 @@ async def operator_login(body: OperatorLoginRequest) -> OperatorLoginResponse: headers={"WWW-Authenticate": "Bearer"}, ) - token, expires_at = create_operator_token(body.operator_id) + # Look up org membership for this operator + org_repo = OrgRepository(session) + membership = await org_repo.get_first_membership(body.operator_id) + + if membership: + org_id = membership.org_id + role = membership.role + else: + # Auto-enroll in default org as owner (backward compat for existing operators) + org_id = "default" + role = "owner" + await org_repo.ensure_default_org() + await org_repo.add_member(org_id, body.operator_id, "owner") + + token, expires_at = create_operator_token(body.operator_id, org_id=org_id, role=role) return OperatorLoginResponse( operator_id=body.operator_id, token=token, expires_at=expires_at, + org_id=org_id, + role=role, ) @router.post("/refresh", response_model=OperatorLoginResponse, status_code=200) async def operator_refresh( - operator_id: Annotated[str, Depends(require_operator_auth)], + auth: Annotated[OperatorContext, Depends(require_operator_auth)], ) -> OperatorLoginResponse: """ Exchange a valid session token for a new one with a fresh expiry. Requires a valid (non-expired, non-revoked) operator Bearer token. The old token remains valid until its original expiry — clients should - immediately replace it with the returned token. The overlap window is - bounded by OPERATOR_TOKEN_EXPIRE_HOURS (default: 8h). - - Intended use: the web client schedules a refresh ~5 minutes before expiry - so the operator never sees an unexpected 401 during an active session. + immediately replace it with the returned token. Returns 401 if the presented token is invalid, expired, or revoked. """ - token, expires_at = create_operator_token(operator_id) + token, expires_at = create_operator_token(auth.operator_id, org_id=auth.org_id, role=auth.role) return OperatorLoginResponse( - operator_id=operator_id, + operator_id=auth.operator_id, token=token, expires_at=expires_at, + org_id=auth.org_id, + role=auth.role, ) diff --git a/apps/api/app/routers/orgs.py b/apps/api/app/routers/orgs.py new file mode 100644 index 0000000..d93e8cf --- /dev/null +++ b/apps/api/app/routers/orgs.py @@ -0,0 +1,439 @@ +""" +AgentLink API — /orgs router (Phase 4) + +Handles organization management, membership RBAC, invitations, and trust links. + +Endpoint summary: + GET /orgs → list organizations for the authenticated operator + POST /orgs → create a new organization (any operator) + GET /orgs/{org_id} → get organization details (members only) + GET /orgs/{org_id}/members → list org members (members only) + POST /orgs/{org_id}/members → add a member (admin+) + PATCH /orgs/{org_id}/members/{op} → update member role (admin+) + DELETE /orgs/{org_id}/members/{op} → remove a member (admin+) + POST /orgs/{org_id}/invites → create an invite token (admin+) + GET /orgs/{org_id}/invites → list invites (admin+) + POST /orgs/{org_id}/invites/{id}/revoke → revoke an invite (admin+) + POST /invites/accept → accept an invite (any authenticated operator) + POST /orgs/{org_id}/trust-links → create a trust link (admin+) + GET /orgs/{org_id}/trust-links → list trust links (operator+) + POST /orgs/{org_id}/trust-links/{id}/revoke → revoke a trust link (admin+) + +Auth: + All endpoints require operator session JWT. + Membership checks enforce org-scoped access. + Role checks enforce minimum privilege level. +""" + +from __future__ import annotations + +import secrets +import uuid +from datetime import datetime, timedelta, timezone +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth import OperatorContext, require_operator_auth +from app.database import get_db +from app.db.repository import AuditRepository, OrgRepository +from app.models.enums import AuditAction +from app.models.org import ( + AcceptInviteRequest, + AddMemberRequest, + CreateInviteRequest, + CreateOrganizationRequest, + CreateTrustLinkRequest, + InviteResponse, + Organization, + OrgInvite, + OrgMembership, + TrustLink, + UpdateMemberRoleRequest, +) +from app.services import audit as audit_svc + +router = APIRouter() + + +# ── Dependency helpers ─────────────────────────────────────────────────────── + + +def get_org_repo(session: Annotated[AsyncSession, Depends(get_db)]) -> OrgRepository: + return OrgRepository(session) + + +def get_audit_repo(session: Annotated[AsyncSession, Depends(get_db)]) -> AuditRepository: + return AuditRepository(session) + + +async def _require_membership( + org_id: str, + auth: OperatorContext, + org_repo: OrgRepository, + min_role: str = "viewer", +) -> OrgMembership: + """Check that the authenticated operator is a member of the org with sufficient role.""" + membership = await org_repo.get_membership(org_id, auth.operator_id) + if not membership: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You are not a member of this organization.", + ) + from app.auth import ROLE_HIERARCHY + if ROLE_HIERARCHY.get(membership.role, 99) > ROLE_HIERARCHY.get(min_role, 99): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Requires at least '{min_role}' role. Your role: '{membership.role}'.", + ) + return membership + + +# ── Organizations ──────────────────────────────────────────────────────────── + + +@router.get("", response_model=list[Organization]) +async def list_orgs( + auth: Annotated[OperatorContext, Depends(require_operator_auth)], + org_repo: Annotated[OrgRepository, Depends(get_org_repo)], +) -> list[Organization]: + """List all organizations. Returns only orgs the operator belongs to.""" + all_orgs = await org_repo.list_orgs() + # Filter to orgs where operator has membership + result = [] + for org in all_orgs: + m = await org_repo.get_membership(org.org_id, auth.operator_id) + if m: + result.append(org) + return result + + +@router.post("", response_model=Organization, status_code=201) +async def create_org( + body: CreateOrganizationRequest, + auth: Annotated[OperatorContext, Depends(require_operator_auth)], + org_repo: Annotated[OrgRepository, Depends(get_org_repo)], + audit_repo: Annotated[AuditRepository, Depends(get_audit_repo)], +) -> Organization: + """Create a new organization. The creating operator becomes the owner.""" + org_id = uuid.uuid4().hex[:16] + try: + org = await org_repo.create_org(org_id, body.name, body.slug, body.description) + except Exception: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Organization with slug '{body.slug}' already exists.", + ) + await org_repo.add_member(org_id, auth.operator_id, "owner") + await audit_svc.emit( + audit_repo=audit_repo, + action=AuditAction.org_created, + actor_id=auth.operator_id, + actor_type="operator", + resource_id=org_id, + resource_type="org", + description=f"Organization '{body.name}' created", + ) + return org + + +@router.get("/{org_id}", response_model=Organization) +async def get_org( + org_id: str, + auth: Annotated[OperatorContext, Depends(require_operator_auth)], + org_repo: Annotated[OrgRepository, Depends(get_org_repo)], +) -> Organization: + """Get organization details. Requires membership.""" + await _require_membership(org_id, auth, org_repo) + org = await org_repo.get_org(org_id) + if not org: + raise HTTPException(status_code=404, detail="Organization not found.") + return org + + +# ── Members ────────────────────────────────────────────────────────────────── + + +@router.get("/{org_id}/members", response_model=list[OrgMembership]) +async def list_members( + org_id: str, + auth: Annotated[OperatorContext, Depends(require_operator_auth)], + org_repo: Annotated[OrgRepository, Depends(get_org_repo)], +) -> list[OrgMembership]: + """List all members of an organization. Requires membership.""" + await _require_membership(org_id, auth, org_repo) + return await org_repo.list_members(org_id) + + +@router.post("/{org_id}/members", response_model=OrgMembership, status_code=201) +async def add_member( + org_id: str, + body: AddMemberRequest, + auth: Annotated[OperatorContext, Depends(require_operator_auth)], + org_repo: Annotated[OrgRepository, Depends(get_org_repo)], + audit_repo: Annotated[AuditRepository, Depends(get_audit_repo)], +) -> OrgMembership: + """Add a member to an organization. Requires admin+ role.""" + await _require_membership(org_id, auth, org_repo, min_role="admin") + existing = await org_repo.get_membership(org_id, body.operator_id) + if existing: + raise HTTPException(status_code=409, detail="Operator is already a member.") + membership = await org_repo.add_member(org_id, body.operator_id, body.role) + await audit_svc.emit( + audit_repo=audit_repo, + action=AuditAction.member_added, + actor_id=auth.operator_id, + actor_type="operator", + resource_id=body.operator_id, + resource_type="membership", + description=f"Added {body.operator_id} as {body.role} to org {org_id}", + ) + return membership + + +@router.patch("/{org_id}/members/{operator_id}", response_model=OrgMembership) +async def update_member_role( + org_id: str, + operator_id: str, + body: UpdateMemberRoleRequest, + auth: Annotated[OperatorContext, Depends(require_operator_auth)], + org_repo: Annotated[OrgRepository, Depends(get_org_repo)], + audit_repo: Annotated[AuditRepository, Depends(get_audit_repo)], +) -> OrgMembership: + """Update a member's role. Requires admin+ role.""" + await _require_membership(org_id, auth, org_repo, min_role="admin") + updated = await org_repo.update_member_role(org_id, operator_id, body.role) + if not updated: + raise HTTPException(status_code=404, detail="Member not found.") + await audit_svc.emit( + audit_repo=audit_repo, + action=AuditAction.member_role_changed, + actor_id=auth.operator_id, + actor_type="operator", + resource_id=operator_id, + resource_type="membership", + description=f"Changed {operator_id} role to {body.role} in org {org_id}", + ) + return updated + + +@router.delete("/{org_id}/members/{operator_id}", status_code=204, response_model=None) +async def remove_member( + org_id: str, + operator_id: str, + auth: Annotated[OperatorContext, Depends(require_operator_auth)], + org_repo: Annotated[OrgRepository, Depends(get_org_repo)], + audit_repo: Annotated[AuditRepository, Depends(get_audit_repo)], +) -> None: + """Remove a member from an organization. Requires admin+ role.""" + await _require_membership(org_id, auth, org_repo, min_role="admin") + if operator_id == auth.operator_id: + raise HTTPException(status_code=400, detail="Cannot remove yourself.") + removed = await org_repo.remove_member(org_id, operator_id) + if not removed: + raise HTTPException(status_code=404, detail="Member not found.") + await audit_svc.emit( + audit_repo=audit_repo, + action=AuditAction.member_removed, + actor_id=auth.operator_id, + actor_type="operator", + resource_id=operator_id, + resource_type="membership", + description=f"Removed {operator_id} from org {org_id}", + ) + + +# ── Invites ────────────────────────────────────────────────────────────────── + + +@router.post("/{org_id}/invites", response_model=InviteResponse, status_code=201) +async def create_invite( + org_id: str, + body: CreateInviteRequest, + auth: Annotated[OperatorContext, Depends(require_operator_auth)], + org_repo: Annotated[OrgRepository, Depends(get_org_repo)], + audit_repo: Annotated[AuditRepository, Depends(get_audit_repo)], +) -> InviteResponse: + """Create an organization invite token. Requires admin+ role. Token shown once.""" + await _require_membership(org_id, auth, org_repo, min_role="admin") + invite_id = uuid.uuid4().hex[:16] + invite_token = secrets.token_urlsafe(32) + expires_at = datetime.now(timezone.utc) + timedelta(hours=body.expires_hours) + + invite = await org_repo.create_invite( + invite_id=invite_id, + org_id=org_id, + invited_by=auth.operator_id, + invite_token=invite_token, + role=body.role, + expires_at=expires_at, + ) + await audit_svc.emit( + audit_repo=audit_repo, + action=AuditAction.invite_sent, + actor_id=auth.operator_id, + actor_type="operator", + resource_id=invite_id, + resource_type="invite", + description=f"Invite created for org {org_id} with role {body.role}", + ) + return InviteResponse( + invite_id=invite.invite_id, + org_id=invite.org_id, + invite_token=invite_token, + role=invite.role, + expires_at=invite.expires_at, + ) + + +@router.get("/{org_id}/invites", response_model=list[OrgInvite]) +async def list_invites( + org_id: str, + auth: Annotated[OperatorContext, Depends(require_operator_auth)], + org_repo: Annotated[OrgRepository, Depends(get_org_repo)], +) -> list[OrgInvite]: + """List invites for an organization. Requires admin+ role.""" + await _require_membership(org_id, auth, org_repo, min_role="admin") + return await org_repo.list_invites(org_id) + + +@router.post("/{org_id}/invites/{invite_id}/revoke", response_model=OrgInvite) +async def revoke_invite( + org_id: str, + invite_id: str, + auth: Annotated[OperatorContext, Depends(require_operator_auth)], + org_repo: Annotated[OrgRepository, Depends(get_org_repo)], + audit_repo: Annotated[AuditRepository, Depends(get_audit_repo)], +) -> OrgInvite: + """Revoke a pending invite. Requires admin+ role.""" + await _require_membership(org_id, auth, org_repo, min_role="admin") + invite = await org_repo.revoke_invite(invite_id) + if not invite: + raise HTTPException(status_code=404, detail="Invite not found.") + await audit_svc.emit( + audit_repo=audit_repo, + action=AuditAction.invite_revoked, + actor_id=auth.operator_id, + actor_type="operator", + resource_id=invite_id, + resource_type="invite", + description=f"Invite {invite_id} revoked in org {org_id}", + ) + return invite + + +# ── Accept invite (not org-scoped — uses token) ───────────────────────────── + + +@router.post("/invites/accept", response_model=OrgMembership, status_code=201) +async def accept_invite( + body: AcceptInviteRequest, + auth: Annotated[OperatorContext, Depends(require_operator_auth)], + org_repo: Annotated[OrgRepository, Depends(get_org_repo)], + audit_repo: Annotated[AuditRepository, Depends(get_audit_repo)], +) -> OrgMembership: + """Accept an invite using a token. Creates membership in the org.""" + invite = await org_repo.get_invite_by_token(body.invite_token) + if not invite: + raise HTTPException(status_code=404, detail="Invalid or unknown invite token.") + if invite.status != "pending": + raise HTTPException(status_code=400, detail=f"Invite is {invite.status}, not pending.") + # Compare expiry — handle both timezone-aware and naive datetimes (SQLite returns naive) + now_utc = datetime.now(timezone.utc) + invite_expiry = invite.expires_at if invite.expires_at.tzinfo else invite.expires_at.replace(tzinfo=timezone.utc) + if invite_expiry < now_utc: + raise HTTPException(status_code=400, detail="Invite has expired.") + + # Check if already a member + existing = await org_repo.get_membership(invite.org_id, auth.operator_id) + if existing: + raise HTTPException(status_code=409, detail="You are already a member of this organization.") + + # Accept the invite + await org_repo.accept_invite(body.invite_token, auth.operator_id) + membership = await org_repo.add_member(invite.org_id, auth.operator_id, invite.role) + + await audit_svc.emit( + audit_repo=audit_repo, + action=AuditAction.invite_accepted, + actor_id=auth.operator_id, + actor_type="operator", + resource_id=invite.invite_id, + resource_type="invite", + description=f"Invite accepted; {auth.operator_id} joined org {invite.org_id} as {invite.role}", + ) + return membership + + +# ── Trust Links ────────────────────────────────────────────────────────────── + + +@router.post("/{org_id}/trust-links", response_model=TrustLink, status_code=201) +async def create_trust_link( + org_id: str, + body: CreateTrustLinkRequest, + auth: Annotated[OperatorContext, Depends(require_operator_auth)], + org_repo: Annotated[OrgRepository, Depends(get_org_repo)], + audit_repo: Annotated[AuditRepository, Depends(get_audit_repo)], +) -> TrustLink: + """Create a directional trust link between two nodes. Requires admin+ role.""" + await _require_membership(org_id, auth, org_repo, min_role="admin") + if body.node_a_id == body.node_b_id: + raise HTTPException(status_code=400, detail="Cannot create trust link to self.") + if await org_repo.has_active_trust_link(org_id, body.node_a_id, body.node_b_id): + raise HTTPException(status_code=409, detail="An active trust link already exists between these nodes.") + link_id = uuid.uuid4().hex[:16] + link = await org_repo.create_trust_link( + link_id=link_id, + org_id=org_id, + node_a_id=body.node_a_id, + node_b_id=body.node_b_id, + created_by=auth.operator_id, + ) + await audit_svc.emit( + audit_repo=audit_repo, + action=AuditAction.trust_link_created, + actor_id=auth.operator_id, + actor_type="operator", + resource_id=link_id, + resource_type="trust_link", + description=f"Trust link {body.node_a_id}→{body.node_b_id} created in org {org_id}", + ) + return link + + +@router.get("/{org_id}/trust-links", response_model=list[TrustLink]) +async def list_trust_links( + org_id: str, + auth: Annotated[OperatorContext, Depends(require_operator_auth)], + org_repo: Annotated[OrgRepository, Depends(get_org_repo)], +) -> list[TrustLink]: + """List trust links in an organization. Requires operator+ role.""" + await _require_membership(org_id, auth, org_repo, min_role="operator") + return await org_repo.list_trust_links(org_id) + + +@router.post("/{org_id}/trust-links/{link_id}/revoke", response_model=TrustLink) +async def revoke_trust_link( + org_id: str, + link_id: str, + auth: Annotated[OperatorContext, Depends(require_operator_auth)], + org_repo: Annotated[OrgRepository, Depends(get_org_repo)], + audit_repo: Annotated[AuditRepository, Depends(get_audit_repo)], +) -> TrustLink: + """Revoke a trust link. Requires admin+ role.""" + await _require_membership(org_id, auth, org_repo, min_role="admin") + link = await org_repo.revoke_trust_link(link_id) + if not link: + raise HTTPException(status_code=404, detail="Trust link not found.") + await audit_svc.emit( + audit_repo=audit_repo, + action=AuditAction.trust_link_revoked, + actor_id=auth.operator_id, + actor_type="operator", + resource_id=link_id, + resource_type="trust_link", + description=f"Trust link {link_id} revoked in org {org_id}", + ) + return link diff --git a/apps/api/app/routers/tasks.py b/apps/api/app/routers/tasks.py index 75ee30a..42e1db1 100644 --- a/apps/api/app/routers/tasks.py +++ b/apps/api/app/routers/tasks.py @@ -65,7 +65,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status from sqlalchemy.ext.asyncio import AsyncSession -from app.auth import require_node_auth, require_operator_auth +from app.auth import OperatorContext, optional_operator_auth, require_auth_or_demo, require_node_auth, require_operator_auth from app.database import get_db from app.db.repository import AgentRepository, ArtifactRepository, AuditRepository, TaskRepository from app.models.enums import AuditAction, PolicyEffect, TaskStatus @@ -88,6 +88,15 @@ router = APIRouter() +# ── Org-check helpers ──────────────────────────────────────────────────────── + + +def _check_task_org(task: Task, auth: OperatorContext) -> None: + """Raise 404 if the task belongs to a different org than the operator.""" + if task.org_id and task.org_id != auth.org_id: + raise HTTPException(status_code=404, detail=f"Task {task.task_id!r} not found") + + # ── Dependency helpers ──────────────────────────────────────────────────────── @@ -277,7 +286,6 @@ async def list_tasks( node_id: str | None = None, target_node_id: str | None = None, task_status: TaskStatus | None = None, - # M31: additional high-value filters task_type: str | None = None, retry_origin: Literal["automatic", "manual"] | None = None, failure_class: Literal["transient", "terminal"] | None = None, @@ -286,29 +294,15 @@ async def list_tasks( limit: int = 50, offset: int = 0, task_repo: Annotated[TaskRepository, Depends(get_task_repo)] = ..., + auth_operator: Annotated[OperatorContext | None, Depends(require_auth_or_demo)] = None, ) -> TaskPage: """ - List tasks with optional server-side filters and pagination. - - Results are newest-first. Response is a TaskPage envelope: - { tasks: [...], total: N, limit: N, offset: N } - - Filters (M31/M44): - target_node_id — node_id of the assigned target node (M44; node_id is a legacy alias) - node_id — legacy alias for target_node_id (target_node_id takes precedence) - task_status — TaskStatus enum value (e.g. pending, executing, failed) - task_type — task_type string (e.g. "research", "code_generation") - retry_origin — "automatic" | "manual" — current requeue provenance - failure_class — "transient" | "terminal" — most recent failure class - requester_user_id — user_id of the task requester - requester_node_id — node_id of the requesting node (M44) - - Pagination: - limit — max rows per page (default 50, capped at 200) - offset — rows to skip (default 0); advance by limit per page + List tasks with server-side filters and pagination. + Requires auth (401) unless REQUIRE_AUTH_FOR_READS=false (demo mode). + When authenticated, results are scoped to the operator's org. """ - # target_node_id takes precedence over the legacy node_id alias. effective_node_id = target_node_id or node_id + org_id = auth_operator.org_id if auth_operator else None return await task_repo.list_tasks_page( node_id=effective_node_id, status=task_status, @@ -319,6 +313,7 @@ async def list_tasks( requester_node_id=requester_node_id, limit=min(limit, 200), offset=offset, + org_id=org_id, ) @@ -329,7 +324,7 @@ async def list_tasks( @router.get("/pending-approval", response_model=list[Task]) async def list_pending_approval_tasks( - auth_operator: Annotated[str, Depends(require_operator_auth)], + auth_operator: Annotated[OperatorContext, Depends(require_operator_auth)], task_repo: Annotated[TaskRepository, Depends(get_task_repo)], ) -> list[Task]: """ @@ -340,7 +335,7 @@ async def list_pending_approval_tasks( Requires operator session JWT (POST /operator/login). """ - return await task_repo.list_pending_approval() + return await task_repo.list_pending_approval(org_id=auth_operator.org_id) # ── Get task ────────────────────────────────────────────────────────────────── @@ -350,11 +345,15 @@ async def list_pending_approval_tasks( async def get_task( task_id: str, task_repo: Annotated[TaskRepository, Depends(get_task_repo)], + auth: Annotated[OperatorContext | None, Depends(require_auth_or_demo)] = None, ) -> Task: - """Get a task by ID, including current status and artifact refs.""" + """Get a task by ID. Requires auth unless demo mode. Org-scoped when authenticated.""" task = await task_repo.get(task_id) if task is None: raise HTTPException(status_code=404, detail=f"Task {task_id!r} not found") + # Org scoping: if authenticated, verify the task belongs to the operator's org + if auth and task.org_id and task.org_id != auth.org_id: + raise HTTPException(status_code=404, detail=f"Task {task_id!r} not found") return task @@ -366,19 +365,17 @@ async def get_task_artifacts( task_id: str, task_repo: Annotated[TaskRepository, Depends(get_task_repo)], artifact_repo: Annotated[ArtifactRepository, Depends(get_artifact_repo)], + auth: Annotated[OperatorContext | None, Depends(require_auth_or_demo)] = None, ) -> list[Artifact]: """ Return full Artifact objects for a completed task (M13). - - The main GET /tasks/{id} endpoint returns ArtifactRef (no payload). - This endpoint returns full Artifact objects: - - Inline artifacts: payload field is populated. - - File-backed artifacts: payload is null; uri points to - GET /artifacts/{artifact_id}/payload for on-demand retrieval. + Requires auth unless demo mode. Org-scoped through the parent task. """ task = await task_repo.get(task_id) if task is None: raise HTTPException(status_code=404, detail=f"Task {task_id!r} not found") + if auth and task.org_id and task.org_id != auth.org_id: + raise HTTPException(status_code=404, detail=f"Task {task_id!r} not found") return await task_repo.get_artifacts(task_id) @@ -390,7 +387,7 @@ async def approve_task( task_id: str, http_request: Request, body: TaskApprovalRequest, - auth_operator: Annotated[str, Depends(require_operator_auth)], + auth_operator: Annotated[OperatorContext, Depends(require_operator_auth)], task_repo: Annotated[TaskRepository, Depends(get_task_repo)], audit_repo: Annotated[AuditRepository, Depends(get_audit_repo)], ) -> TaskApprovalAck: @@ -405,6 +402,7 @@ async def approve_task( task = await task_repo.get(task_id) if task is None: raise HTTPException(status_code=404, detail=f"Task {task_id!r} not found") + _check_task_org(task, auth_operator) if not can_transition(task.status, TaskStatus.pending): raise HTTPException( @@ -433,7 +431,7 @@ async def approve_task( await audit_svc.emit( audit_repo, action=AuditAction.approval_granted, - actor_id=auth_operator, + actor_id=auth_operator.operator_id, actor_type="operator", resource_id=task_id, resource_type="task", @@ -459,7 +457,7 @@ async def approve_task( async def reject_task( task_id: str, body: TaskApprovalRequest, - auth_operator: Annotated[str, Depends(require_operator_auth)], + auth_operator: Annotated[OperatorContext, Depends(require_operator_auth)], task_repo: Annotated[TaskRepository, Depends(get_task_repo)], audit_repo: Annotated[AuditRepository, Depends(get_audit_repo)], ) -> TaskApprovalAck: @@ -474,6 +472,7 @@ async def reject_task( task = await task_repo.get(task_id) if task is None: raise HTTPException(status_code=404, detail=f"Task {task_id!r} not found") + _check_task_org(task, auth_operator) # Reject is specifically an approval-gate action — only pending_approval tasks # can be rejected via this endpoint (executing → failed goes through /result). @@ -497,7 +496,7 @@ async def reject_task( await audit_svc.emit( audit_repo, action=AuditAction.approval_denied, - actor_id=auth_operator, + actor_id=auth_operator.operator_id, actor_type="operator", resource_id=task_id, resource_type="task", @@ -521,7 +520,7 @@ async def cancel_task( task_id: str, http_request: Request, body: TaskApprovalRequest, - auth_operator: Annotated[str, Depends(require_operator_auth)], + auth_operator: Annotated[OperatorContext, Depends(require_operator_auth)], task_repo: Annotated[TaskRepository, Depends(get_task_repo)], audit_repo: Annotated[AuditRepository, Depends(get_audit_repo)], ) -> TaskApprovalAck: @@ -546,6 +545,7 @@ async def cancel_task( task = await task_repo.get(task_id) if task is None: raise HTTPException(status_code=404, detail=f"Task {task_id!r} not found") + _check_task_org(task, auth_operator) reason = body.reason or "Task cancelled by operator." @@ -573,11 +573,11 @@ async def cancel_task( await audit_svc.emit( audit_repo, action=AuditAction.task_cancel_requested, - actor_id=auth_operator, + actor_id=auth_operator.operator_id, actor_type="operator", resource_id=task_id, resource_type="task", - description=f"Cancellation requested for task '{task.task_type}' by {auth_operator}: {reason}", + description=f"Cancellation requested for task '{task.task_type}' by {auth_operator.operator_id}: {reason}", correlation_id=task_id, metadata={ "task_type": task.task_type, @@ -613,11 +613,11 @@ async def cancel_task( await audit_svc.emit( audit_repo, action=AuditAction.task_cancelled, - actor_id=auth_operator, + actor_id=auth_operator.operator_id, actor_type="operator", resource_id=task_id, resource_type="task", - description=f"Task '{task.task_type}' cancelled by {auth_operator}: {reason}", + description=f"Task '{task.task_type}' cancelled by {auth_operator.operator_id}: {reason}", correlation_id=task_id, metadata={ "task_type": task.task_type, @@ -932,7 +932,7 @@ async def retry_task( task_id: str, http_request: Request, body: TaskApprovalRequest, - auth_operator: Annotated[str, Depends(require_operator_auth)], + auth_operator: Annotated[OperatorContext, Depends(require_operator_auth)], task_repo: Annotated[TaskRepository, Depends(get_task_repo)], audit_repo: Annotated[AuditRepository, Depends(get_audit_repo)], ) -> Task: @@ -971,6 +971,7 @@ async def retry_task( task = await task_repo.get(task_id) if task is None: raise HTTPException(status_code=404, detail=f"Task {task_id!r} not found") + _check_task_org(task, auth_operator) if task.status != TaskStatus.failed: raise HTTPException( @@ -1001,14 +1002,14 @@ async def retry_task( }, ) - description = f"Task '{task.task_type}' manually requeued by operator {auth_operator}" + description = f"Task '{task.task_type}' manually requeued by operator {auth_operator.operator_id}" if body.reason: description += f": {body.reason}" await audit_svc.emit( audit_repo, action=AuditAction.task_manually_retried, - actor_id=auth_operator, + actor_id=auth_operator.operator_id, actor_type="operator", resource_id=task_id, resource_type="task", diff --git a/apps/api/app/settings.py b/apps/api/app/settings.py index 278104e..223f64b 100644 --- a/apps/api/app/settings.py +++ b/apps/api/app/settings.py @@ -47,6 +47,14 @@ class Settings(BaseSettings): # Runtime environment. api_env: str = "development" + # Auth posture for read endpoints (Phase 4). + # When True (production default): all resource list/detail endpoints require + # a valid operator JWT. Unauthenticated requests receive 401. + # When False (demo mode): list/detail endpoints are publicly readable, + # but org-scoping still applies when a token IS provided. + # Set REQUIRE_AUTH_FOR_READS=false in .env for open demo setups. + require_auth_for_reads: bool = True + # Database (Milestone 3) # asyncpg driver for Postgres, aiosqlite for SQLite (tests). # Postgres format: postgresql+asyncpg://user:pass@host:5432/dbname diff --git a/apps/api/coverage.xml b/apps/api/coverage.xml index 5acd8fa..295ffae 100644 --- a/apps/api/coverage.xml +++ b/apps/api/coverage.xml @@ -1,5 +1,5 @@ - + @@ -7,88 +7,111 @@ C:\Users\stern\Documents\Code\AgentLink\apps\api\app - + - + + + + + + + - - - - - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -97,19 +120,19 @@ - - + + - + - + - - - + + + @@ -117,9 +140,9 @@ - + - + @@ -138,7 +161,7 @@ - + @@ -157,33 +180,33 @@ - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - - - + + + + - - + + @@ -195,6 +218,7 @@ + @@ -256,112 +280,110 @@ - + - + - - - + + - - + - + + - + - - + + - - - - - + + + + + - - + - + + - + - - - - - + + + + - + - + - + - - - - - - - - + + + + + + + + - + + - - - + + + - - + + - - + - - - - + + + + + - @@ -369,13 +391,63 @@ - - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -392,247 +464,253 @@ - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - - - - + + + + - - - - - - - + + + + + + + + - + - - - - - - - - + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - - - - - - + + + + + + + + - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + + + - + - - - - - - - - - + + + + + + + + + - - - + + + - - - - - - + + + + + + - - - - - + + + + + - - + + - + @@ -647,247 +725,387 @@ - - + + - - - - - - - - + + + + + + - - - + + + - - - - - - + + + + + + - + - - + + - - - - - + + + + + + + + - - - - + - - + + - - - - - - + + + + + + - + - + - - + + - - - - - - + + + + + + - + - + - - + + - - - - - - - + + + + + + + + + + - - + - - - - - - - - - + + + + + + + + - - + + + + - - - - + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - + + - - - - - - - + + + + + + + - - + + - - + - - - + + + - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1041,29 +1259,50 @@ + + - + - - + - - - - + + + + + + + + + + + + + + + + + + + + + + + + @@ -1165,6 +1404,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1269,13 +1574,13 @@ - + - + @@ -1286,10 +1591,10 @@ - + - + @@ -1299,7 +1604,7 @@ - + @@ -1313,16 +1618,16 @@ - + - + - + @@ -1339,7 +1644,7 @@ - + @@ -1354,22 +1659,22 @@ - + - - - - - - - - - + + + + + + + + + - + @@ -1380,10 +1685,10 @@ - + - + @@ -1409,111 +1714,112 @@ - + - + - + - - - - + + + + - + - + - + - - - + + + - + - + - - - + + + - - + + - + - + - + - + - + - - - - + + + + - - - - - + + + + + - - - - - + + + + + - - - - - - + + + + + + - - - - - - + + + + + + - - - - + + + + - - - + + + + - + @@ -1522,46 +1828,207 @@ - - + + + - - + + - + - + - + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + + + + + + + - - - - - - - - - + + + + + + + + + + + + + - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -1582,20 +2049,20 @@ - + - + - + - + - + - + @@ -1603,158 +2070,159 @@ - - + + - + - + - - - - - + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + - - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -1771,48 +2239,48 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + - + - + @@ -1824,30 +2292,30 @@ - - + + - + - + - - - - + + + + - + - - + + - - + + - + @@ -1862,12 +2330,12 @@ - + - + @@ -1876,18 +2344,18 @@ - - - - - - - - - + + + + + + + + + - + @@ -1895,15 +2363,15 @@ - - + + - - + + @@ -1914,37 +2382,37 @@ - - + + - + - + - + - + - + @@ -1958,14 +2426,14 @@ - - - - + + + + - - - + + + @@ -1980,7 +2448,7 @@ - + @@ -1992,14 +2460,14 @@ - - - - + + + + - - - + + + diff --git a/apps/api/main.py b/apps/api/main.py index 3c0b953..cd735f4 100644 --- a/apps/api/main.py +++ b/apps/api/main.py @@ -39,7 +39,7 @@ from app.connection_manager import ConnectionManager from app.database import AsyncSessionLocal, Base, engine -from app.routers import artifacts, audit, health, nodes, agents, operator, tasks, ws +from app.routers import artifacts, audit, health, nodes, agents, operator, orgs, tasks, ws from app.settings import settings from app.tasks.stale_detector import run_stale_detector from app.tasks.artifact_cleanup import run_artifact_cleanup @@ -170,3 +170,4 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: app.include_router(artifacts.router, prefix="/artifacts", tags=["Artifacts"]) # M13 app.include_router(audit.router, prefix="/audit", tags=["Audit"]) app.include_router(operator.router, prefix="/operator", tags=["Operator"]) +app.include_router(orgs.router, prefix="/orgs", tags=["Organizations"]) diff --git a/apps/api/scripts/seed_demo.py b/apps/api/scripts/seed_demo.py new file mode 100644 index 0000000..b07eac3 --- /dev/null +++ b/apps/api/scripts/seed_demo.py @@ -0,0 +1,800 @@ +#!/usr/bin/env python3 +""" +AgentLink — local demo data seed script + +Creates realistic demo nodes, agents, tasks, artifacts, and audit events +so the operator UI has meaningful data immediately after startup. + +Usage: + cd apps/api + python scripts/seed_demo.py + + # Optional: wipe existing data first + python scripts/seed_demo.py --reset + +Prerequisites: + - Postgres running (docker compose -f docker-compose.dev.yml up -d postgres) + - Migrations applied (alembic upgrade head) + +Seeded credentials: + Operator login: + operator_id : demo + api_key : dev-operator-key-change-in-production + +This script is DEVELOPMENT ONLY. Never run against a production database. +""" + +import asyncio +import json +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +# --------------------------------------------------------------------------- +# Path setup — allow running as `python scripts/seed_demo.py` from apps/api/ +# --------------------------------------------------------------------------- +repo_api = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(repo_api)) + +from sqlalchemy import text # noqa: E402 +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine # noqa: E402 +from sqlalchemy.orm import sessionmaker # noqa: E402 + +from app.db.models import ( # noqa: E402 + AgentRecord, + ArtifactRecord, + AuditEventRecord, + NodeRecord, + OrganizationRecord, + OrgMembershipRecord, + TaskRecord, +) +from app.settings import settings # noqa: E402 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def ago(**kwargs) -> datetime: + return utcnow() - timedelta(**kwargs) + + +def uid(prefix: str, n: int) -> str: + return f"{prefix}-{n:03d}" + + +# --------------------------------------------------------------------------- +# Demo data definitions +# --------------------------------------------------------------------------- + +def _cap(cap_id: str, category: str, display_name: str, version: str = "1.0.0") -> dict: + """Build a Capability dict matching the Pydantic Capability model.""" + return { + "capability_id": cap_id, + "category": category, + "display_name": display_name, + "description": None, + "version": version, + "metadata": None, + } + + +def _caps_summary(caps: list[dict]) -> dict: + """Build a CapabilitySummary dict as stored in NodeRecord.capabilities.""" + return { + "capabilities": caps, + "last_updated": ago(hours=1).isoformat(), + } + + +NODES = [ + { + "node_id": "node-alpha-001", + "owner_id": "user-jake-001", + "org_id": "default", + "device_name": "Alpha Workstation", + "platform": "linux", + "version": "0.1.0", + "status": "online", + "public_key": "demo-public-key-alpha", + "installed_adapters": ["generic_rest"], + "capabilities": _caps_summary([ + _cap("browser_research", "browser_research", "Browser Research"), + _cap("web_search", "web_search", "Web Search"), + _cap("execute_code", "execute_code", "Execute Code"), + _cap("shell_tooling", "shell_tooling", "Shell Tooling"), + ]), + "adapter_statuses": [ + {"adapter_type": "generic_rest", "last_status": "ok", "agent_count": 2} + ], + "last_heartbeat": ago(seconds=15), + "created_at": ago(hours=72), + "updated_at": ago(seconds=15), + }, + { + "node_id": "node-beta-001", + "owner_id": "user-jake-001", + "org_id": "default", + "device_name": "Beta Server", + "platform": "linux", + "version": "0.1.0", + "status": "online", + "public_key": "demo-public-key-beta", + "installed_adapters": ["generic_rest"], + "capabilities": _caps_summary([ + _cap("sql_query", "sql_query", "SQL Query"), + _cap("vector_search", "vector_search", "Vector Search"), + _cap("file_artifact_processing", "file_artifact_processing", "File Artifact Processing"), + _cap("execute_code", "execute_code", "Execute Code"), + ]), + "adapter_statuses": [ + {"adapter_type": "generic_rest", "last_status": "ok", "agent_count": 2} + ], + "last_heartbeat": ago(seconds=28), + "created_at": ago(hours=48), + "updated_at": ago(seconds=28), + }, + { + "node_id": "node-gamma-001", + "owner_id": "user-jake-001", + "org_id": "default", + "device_name": "Gamma Device", + "platform": "darwin", + "version": "0.1.0", + "status": "offline", + "public_key": "demo-public-key-gamma", + "installed_adapters": [], + "capabilities": _caps_summary([ + _cap("execute_code", "execute_code", "Execute Code"), + ]), + "adapter_statuses": None, + "last_heartbeat": ago(minutes=45), + "created_at": ago(days=14), + "updated_at": ago(minutes=45), + }, +] + +AGENTS = [ + { + "agent_id": "agent-researcher-001", + "node_id": "node-alpha-001", + "display_name": "Research Agent", + "role": "research", + "description": "Performs web research, summarises findings, and produces structured reports.", + "runtime_type": "generic_rest", + "capabilities": [_cap("browser_research", "browser_research", "Browser Research"), _cap("web_search", "web_search", "Web Search")], + "input_modes": ["text"], + "output_modes": ["text", "json"], + "supported_task_categories": ["research", "summarisation"], + "privacy_class": "internal", + "requires_approval": False, + "status": "available", + "last_discovered_at": ago(minutes=2), + "created_at": ago(hours=72), + "updated_at": ago(minutes=2), + }, + { + "agent_id": "agent-coderunner-001", + "node_id": "node-alpha-001", + "display_name": "Code Runner", + "role": "execution", + "description": "Executes code in a sandboxed environment and returns structured results.", + "runtime_type": "generic_rest", + "capabilities": [_cap("execute_code", "execute_code", "Execute Code"), _cap("shell_tooling", "shell_tooling", "Shell Tooling")], + "input_modes": ["text", "json"], + "output_modes": ["text", "json"], + "supported_task_categories": ["code_execution", "automation"], + "privacy_class": "internal", + "requires_approval": True, + "status": "available", + "last_discovered_at": ago(minutes=2), + "created_at": ago(hours=72), + "updated_at": ago(minutes=2), + }, + { + "agent_id": "agent-dataproc-001", + "node_id": "node-beta-001", + "display_name": "Data Processor", + "role": "data", + "description": "Queries structured data sources and returns results as artifacts.", + "runtime_type": "generic_rest", + "capabilities": [_cap("sql_query", "sql_query", "SQL Query"), _cap("vector_search", "vector_search", "Vector Search")], + "input_modes": ["text", "json"], + "output_modes": ["json"], + "supported_task_categories": ["data_query", "retrieval"], + "privacy_class": "internal", + "requires_approval": False, + "status": "available", + "last_discovered_at": ago(minutes=3), + "created_at": ago(hours=48), + "updated_at": ago(minutes=3), + }, + { + "agent_id": "agent-fileproc-001", + "node_id": "node-beta-001", + "display_name": "File Processor", + "role": "processing", + "description": "Processes file-backed artifacts and produces transformed outputs.", + "runtime_type": "generic_rest", + "capabilities": [_cap("file_artifact_processing", "file_artifact_processing", "File Artifact Processing")], + "input_modes": ["json"], + "output_modes": ["json", "text"], + "supported_task_categories": ["file_processing"], + "privacy_class": "internal", + "requires_approval": False, + "status": "available", + "last_discovered_at": ago(minutes=3), + "created_at": ago(hours=48), + "updated_at": ago(minutes=3), + }, + { + "agent_id": "agent-legacy-001", + "node_id": "node-gamma-001", + "display_name": "Legacy Agent", + "role": "execution", + "description": "General-purpose execution agent on legacy hardware.", + "runtime_type": "generic_rest", + "capabilities": [_cap("execute_code", "execute_code", "Execute Code")], + "input_modes": ["text"], + "output_modes": ["text"], + "supported_task_categories": ["code_execution"], + "privacy_class": "internal", + "requires_approval": False, + "status": "unavailable", + "last_discovered_at": ago(minutes=45), + "created_at": ago(days=14), + "updated_at": ago(minutes=45), + }, +] + +# --------------------------------------------------------------------------- +# Task definitions +# --------------------------------------------------------------------------- +# These are returned as a list of dicts with an extra "_artifacts" key that +# hold ArtifactRecord kwargs to insert after the task. + +def _tasks() -> list[dict]: + now = utcnow() + return [ + # 1 — completed research task with text artifact + { + "task_id": "task-001", + "task_type": "research", + "requester_node_id": "node-alpha-001", + "requester_user_id": "user-jake-001", + "target_node_id": "node-alpha-001", + "target_agent_id": "agent-researcher-001", + "payload": { + "instruction": "Summarise the current state of local-first agent networking standards." + }, + "priority": 7, + "status": "completed", + "attempt_count": 1, + "max_attempts": 3, + "artifacts": [ + { + "artifact_id": "artifact-001", + "artifact_type": "text", + "label": "Research Report", + "mime_type": "text/plain", + "size_bytes": 312, + "storage_mode": "inline", + } + ], + "created_at": ago(hours=6), + "updated_at": ago(hours=5, minutes=45), + }, + # 2 — completed code execution with JSON artifact + { + "task_id": "task-002", + "task_type": "code_execution", + "requester_node_id": "node-beta-001", + "requester_user_id": "user-jake-001", + "target_node_id": "node-alpha-001", + "target_agent_id": "agent-coderunner-001", + "payload": { + "instruction": "Run the data validation pipeline and return a summary.", + "context": {"language": "python"}, + }, + "priority": 6, + "status": "completed", + "attempt_count": 1, + "max_attempts": 3, + "artifacts": [ + { + "artifact_id": "artifact-002", + "artifact_type": "json", + "label": "Validation Results", + "mime_type": "application/json", + "size_bytes": 218, + "storage_mode": "inline", + } + ], + "created_at": ago(hours=5), + "updated_at": ago(hours=4, minutes=50), + }, + # 3 — failed (terminal failure) + { + "task_id": "task-003", + "task_type": "data_query", + "requester_node_id": "node-alpha-001", + "requester_user_id": "user-jake-001", + "target_node_id": "node-beta-001", + "target_agent_id": "agent-dataproc-001", + "payload": {"instruction": "Query the events table for the last 7 days of activity."}, + "priority": 5, + "status": "failed", + "attempt_count": 3, + "max_attempts": 3, + "last_failure_class": "terminal", + "error": { + "code": "QUERY_TIMEOUT", + "message": "Database query exceeded the 30s execution limit after 3 attempts.", + }, + "artifacts": [], + "created_at": ago(hours=4), + "updated_at": ago(hours=3, minutes=20), + }, + # 4 — currently executing + { + "task_id": "task-004", + "task_type": "research", + "requester_node_id": "node-beta-001", + "requester_user_id": "user-jake-001", + "target_node_id": "node-alpha-001", + "target_agent_id": "agent-researcher-001", + "payload": { + "instruction": "Research recent developments in MCP tool protocol adoption." + }, + "priority": 8, + "status": "executing", + "attempt_count": 1, + "max_attempts": 3, + "artifacts": [], + "created_at": ago(minutes=12), + "updated_at": ago(minutes=3), + }, + # 5 — pending (waiting to be claimed) + { + "task_id": "task-005", + "task_type": "code_execution", + "requester_node_id": "node-alpha-001", + "requester_user_id": "user-jake-001", + "target_node_id": "node-alpha-001", + "target_agent_id": "agent-coderunner-001", + "payload": { + "instruction": "Run the nightly metric aggregation script.", + "context": {"language": "python"}, + }, + "priority": 4, + "status": "pending", + "attempt_count": 0, + "max_attempts": 2, + "artifacts": [], + "created_at": ago(minutes=5), + "updated_at": ago(minutes=5), + }, + # 6 — awaiting operator approval (policy gate) + { + "task_id": "task-006", + "task_type": "file_processing", + "requester_node_id": "node-alpha-001", + "requester_user_id": "user-jake-001", + "target_node_id": "node-beta-001", + "target_agent_id": "agent-fileproc-001", + "payload": { + "instruction": "Process and transform the uploaded dataset archive.", + "context": {"file_ref": "upload-abc123"}, + }, + "priority": 6, + "status": "pending_approval", + "policy_decision": { + "policy_id": "policy-approval-required", + "effect": "require_approval", + "reason": "File processing tasks require operator approval before execution.", + }, + "attempt_count": 0, + "max_attempts": 1, + "artifacts": [], + "created_at": ago(minutes=18), + "updated_at": ago(minutes=18), + }, + # 7 — denied by operator + { + "task_id": "task-007", + "task_type": "shell_exec", + "requester_node_id": "node-alpha-001", + "requester_user_id": "user-jake-001", + "target_node_id": "node-alpha-001", + "target_agent_id": "agent-coderunner-001", + "payload": {"instruction": "rm -rf /tmp/cache/*", "context": {"language": "bash"}}, + "priority": 3, + "status": "denied", + "policy_decision": { + "policy_id": "policy-block-destructive", + "effect": "deny", + "reason": "Shell exec tasks with destructive flags are not permitted.", + }, + "operator_note": "Denied — destructive shell commands require explicit approval with audit trail.", + "attempt_count": 0, + "max_attempts": 1, + "artifacts": [], + "created_at": ago(hours=2), + "updated_at": ago(hours=1, minutes=50), + }, + # 8 — cancelled + { + "task_id": "task-008", + "task_type": "code_execution", + "requester_node_id": "node-beta-001", + "requester_user_id": "user-jake-001", + "target_node_id": "node-alpha-001", + "target_agent_id": "agent-coderunner-001", + "payload": {"instruction": "Run the full regression suite.", "context": {"language": "python"}}, + "priority": 5, + "status": "cancelled", + "operator_note": "Cancelled — superseded by task-005.", + "attempt_count": 0, + "max_attempts": 2, + "artifacts": [], + "created_at": ago(hours=3), + "updated_at": ago(hours=2, minutes=55), + }, + # 9 — awaiting_retry (transient failure, scheduled backoff) + { + "task_id": "task-009", + "task_type": "research", + "requester_node_id": "node-alpha-001", + "requester_user_id": "user-jake-001", + "target_node_id": "node-alpha-001", + "target_agent_id": "agent-researcher-001", + "payload": {"instruction": "Fetch and summarise the latest arXiv abstracts for LLM routing."}, + "priority": 5, + "status": "pending", + "attempt_count": 1, + "max_attempts": 3, + "last_failure_class": "transient", + "retry_after": now + timedelta(seconds=45), + "retry_origin": "automatic", + "error": { + "code": "UPSTREAM_TIMEOUT", + "message": "External research API returned 503. Scheduled retry in 45s.", + }, + "artifacts": [], + "created_at": ago(minutes=8), + "updated_at": ago(minutes=1), + }, + # 10 — pending with no live target (gamma is offline) + { + "task_id": "task-010", + "task_type": "data_query", + "requester_node_id": "node-alpha-001", + "requester_user_id": "user-jake-001", + "target_node_id": "node-gamma-001", + "target_agent_id": "agent-legacy-001", + "payload": {"instruction": "Run legacy inventory reconciliation script."}, + "priority": 2, + "status": "pending", + "attempt_count": 0, + "max_attempts": 1, + "artifacts": [], + "created_at": ago(minutes=3), + "updated_at": ago(minutes=3), + }, + ] + + +ARTIFACTS = [ + { + "artifact_id": "artifact-001", + "task_id": "task-001", + "produced_by_node_id": "node-alpha-001", + "artifact_type": "text", + "label": "Research Report", + "mime_type": "text/plain", + "size_bytes": 312, + "privacy_class": "internal", + "storage_mode": "inline", + "inline_payload": ( + "# Local-First Agent Networking — State of Standards\n\n" + "Several emerging protocols aim to standardise agent interoperability:\n\n" + "- **MCP (Model Context Protocol)**: Tool access layer from Anthropic, gaining adoption.\n" + "- **A2A (Agent-to-Agent)**: Google's draft peer messaging spec.\n" + "- **AgentLink Protocol**: Local-first enrollment, policy, and task envelope.\n\n" + "No dominant standard has emerged yet. Fragmentation remains high across runtimes." + ), + "created_at": ago(hours=5, minutes=50), + "expires_at": ago(hours=5, minutes=50) + timedelta(days=30), + }, + { + "artifact_id": "artifact-002", + "task_id": "task-002", + "produced_by_node_id": "node-alpha-001", + "artifact_type": "json", + "label": "Validation Results", + "mime_type": "application/json", + "size_bytes": 218, + "privacy_class": "internal", + "storage_mode": "inline", + "inline_payload": json.dumps({ + "status": "passed", + "checks": 47, + "passed": 47, + "failed": 0, + "warnings": 2, + "duration_ms": 1843, + "warnings_detail": ["deprecated field 'legacy_id'", "field 'org_id' is null for 3 records"], + }), + "created_at": ago(hours=4, minutes=52), + "expires_at": ago(hours=4, minutes=52) + timedelta(days=30), + }, +] + + +def _audit_events() -> list[dict]: + return [ + { + "event_id": "audit-001", + "action": "node_linked", + "node_id": "node-alpha-001", + "actor_id": "node-alpha-001", + "actor_type": "node", + "resource_id": "node-alpha-001", + "resource_type": "node", + "outcome": "success", + "description": "Node 'Alpha Workstation' enrolled successfully.", + "metadata_": {"platform": "linux", "version": "0.1.0"}, + "occurred_at": ago(hours=72), + }, + { + "event_id": "audit-002", + "action": "node_linked", + "node_id": "node-beta-001", + "actor_id": "node-beta-001", + "actor_type": "node", + "resource_id": "node-beta-001", + "resource_type": "node", + "outcome": "success", + "description": "Node 'Beta Server' enrolled successfully.", + "metadata_": {"platform": "linux", "version": "0.1.0"}, + "occurred_at": ago(hours=48), + }, + { + "event_id": "audit-003", + "action": "agent_exposed", + "node_id": "node-alpha-001", + "actor_id": "node-alpha-001", + "actor_type": "node", + "resource_id": "agent-researcher-001", + "resource_type": "node", + "outcome": "success", + "description": "Agent 'Research Agent' discovered on Alpha Workstation.", + "metadata_": {"runtime_type": "generic_rest"}, + "occurred_at": ago(hours=71), + }, + { + "event_id": "audit-004", + "action": "task_created", + "node_id": "control_plane", + "actor_id": "user-jake-001", + "actor_type": "user", + "resource_id": "task-001", + "resource_type": "task", + "outcome": "success", + "description": "Task 'research' created and routed to node-alpha-001.", + "metadata_": {"task_type": "research", "target_node_id": "node-alpha-001"}, + "occurred_at": ago(hours=6), + }, + { + "event_id": "audit-005", + "action": "task_completed", + "node_id": "node-alpha-001", + "actor_id": "node-alpha-001", + "actor_type": "node", + "resource_id": "task-001", + "resource_type": "task", + "outcome": "success", + "description": "Task 'research' completed. 1 artifact produced.", + "metadata_": {"artifact_count": 1, "attempt_count": 1}, + "occurred_at": ago(hours=5, minutes=45), + }, + { + "event_id": "audit-006", + "action": "approval_denied", + "node_id": "control_plane", + "actor_id": "demo", + "actor_type": "operator", + "resource_id": "task-007", + "resource_type": "task", + "outcome": "success", + "description": "Operator denied task-007 (shell_exec). Reason: destructive flags not permitted.", + "metadata_": {"task_type": "shell_exec", "operator_id": "demo"}, + "occurred_at": ago(hours=1, minutes=50), + }, + { + "event_id": "audit-007", + "action": "task_cancelled", + "node_id": "control_plane", + "actor_id": "demo", + "actor_type": "operator", + "resource_id": "task-008", + "resource_type": "task", + "outcome": "success", + "description": "Operator cancelled task-008. Note: superseded by task-005.", + "metadata_": {"task_type": "code_execution", "operator_id": "demo"}, + "occurred_at": ago(hours=2, minutes=55), + }, + { + "event_id": "audit-008", + "action": "task_failed", + "node_id": "node-beta-001", + "actor_id": "node-beta-001", + "actor_type": "node", + "resource_id": "task-003", + "resource_type": "task", + "outcome": "error", + "description": "Task 'data_query' failed after 3 attempts. Failure class: terminal.", + "metadata_": {"failure_class": "terminal", "attempt_count": 3, "error_code": "QUERY_TIMEOUT"}, + "occurred_at": ago(hours=3, minutes=20), + }, + { + "event_id": "audit-009", + "action": "artifact_transferred", + "node_id": "node-alpha-001", + "actor_id": "node-alpha-001", + "actor_type": "node", + "resource_id": "artifact-001", + "resource_type": "task", + "outcome": "success", + "description": "Artifact 'Research Report' created for task-001. Storage: inline.", + "metadata_": {"task_id": "task-001", "storage_mode": "inline", "size_bytes": 312}, + "occurred_at": ago(hours=5, minutes=50), + }, + { + "event_id": "audit-010", + "action": "permission_granted", + "node_id": "control_plane", + "actor_id": "demo", + "actor_type": "operator", + "resource_id": None, + "resource_type": None, + "outcome": "success", + "description": "Operator 'demo' authenticated successfully.", + "metadata_": {"operator_id": "demo"}, + "occurred_at": ago(minutes=30), + }, + ] + + +# --------------------------------------------------------------------------- +# Seed logic +# --------------------------------------------------------------------------- + +async def reset_demo_data(session: AsyncSession) -> None: + """Delete all existing demo data before re-seeding.""" + print(" Resetting existing demo data...") + # Delete in FK-safe order + await session.execute(text("DELETE FROM audit_events")) + await session.execute(text("DELETE FROM artifacts")) + await session.execute(text("DELETE FROM tasks")) + await session.execute(text("DELETE FROM agents")) + await session.execute(text("DELETE FROM nodes")) + await session.execute(text("DELETE FROM org_invites")) + await session.execute(text("DELETE FROM trust_links")) + await session.execute(text("DELETE FROM org_memberships")) + await session.execute(text("DELETE FROM organizations")) + await session.commit() + print(" Reset complete.") + + +async def seed(reset: bool = False) -> None: + engine = create_async_engine(settings.database_url, echo=False) + async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + async with async_session() as session: + if reset: + await reset_demo_data(session) + + print("Seeding default organization...") + session.add(OrganizationRecord( + org_id="default", + name="Default Workspace", + slug="default", + description="Default organization for local development.", + created_at=ago(days=30), + updated_at=ago(days=30), + )) + await session.commit() + + print("Seeding org memberships...") + session.add(OrgMembershipRecord( + membership_id="membership-demo-001", + org_id="default", + operator_id="demo", + role="owner", + created_at=ago(days=30), + updated_at=ago(days=30), + )) + session.add(OrgMembershipRecord( + membership_id="membership-alice-001", + org_id="default", + operator_id="alice", + role="admin", + created_at=ago(days=15), + updated_at=ago(days=15), + )) + session.add(OrgMembershipRecord( + membership_id="membership-bob-001", + org_id="default", + operator_id="bob", + role="operator", + created_at=ago(days=7), + updated_at=ago(days=7), + )) + await session.commit() + print(" 1 organization, 3 memberships seeded.") + + print("Seeding nodes...") + for node_data in NODES: + record = NodeRecord(**node_data) + session.add(record) + await session.commit() + print(f" {len(NODES)} nodes seeded.") + + print("Seeding agents...") + for agent_data in AGENTS: + record = AgentRecord(**agent_data) + session.add(record) + await session.commit() + print(f" {len(AGENTS)} agents seeded.") + + print("Seeding tasks...") + tasks = _tasks() + for task_data in tasks: + task_data = {k: v for k, v in task_data.items()} + record = TaskRecord(**task_data) + session.add(record) + await session.commit() + print(f" {len(tasks)} tasks seeded.") + + print("Seeding artifacts...") + for artifact_data in ARTIFACTS: + record = ArtifactRecord(**artifact_data) + session.add(record) + await session.commit() + print(f" {len(ARTIFACTS)} artifacts seeded.") + + print("Seeding audit events...") + audit_events = _audit_events() + for event_data in audit_events: + record = AuditEventRecord(**event_data) + session.add(record) + await session.commit() + print(f" {len(audit_events)} audit events seeded.") + + await engine.dispose() + + print() + print("Demo data seeded successfully.") + print() + print("Operator login credentials:") + print(" operator_id : demo") + print(" api_key : dev-operator-key-change-in-production") + print() + print("Open the UI at: http://localhost:3000") + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + reset_flag = "--reset" in sys.argv + if reset_flag: + print("Running with --reset: all existing data will be cleared first.") + asyncio.run(seed(reset=reset_flag)) diff --git a/apps/api/tests/conftest.py b/apps/api/tests/conftest.py index aa00e05..36b10ec 100644 --- a/apps/api/tests/conftest.py +++ b/apps/api/tests/conftest.py @@ -32,8 +32,14 @@ async def test_something(client: AsyncClient, operator_headers: dict): from app.connection_manager import ConnectionManager from app.database import Base, get_db +from app.settings import settings from main import app +# Default to demo mode for tests so existing unauthenticated test flows continue +# to pass. Tests that need to validate auth-required behavior should override +# this per-test via `settings.require_auth_for_reads = True`. +settings.require_auth_for_reads = False + # Use in-memory SQLite for all tests. # Each test gets a fresh engine + schema. TEST_DB_URL = "sqlite+aiosqlite:///:memory:" diff --git a/apps/api/tests/demo_walkthrough.py b/apps/api/tests/demo_walkthrough.py new file mode 100644 index 0000000..fed703c --- /dev/null +++ b/apps/api/tests/demo_walkthrough.py @@ -0,0 +1,118 @@ +"""Demo walkthrough — validates full RBAC/invite/trust-link/isolation flow via API.""" +import asyncio +from httpx import AsyncClient, ASGITransport +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from app.database import Base, get_db +from app.connection_manager import ConnectionManager +from main import app + +TEST_DB_URL = "sqlite+aiosqlite:///:memory:" + +async def demo(): + engine = create_async_engine(TEST_DB_URL, echo=False) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + factory = async_sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False) + async def override_get_db(): + async with factory() as session: + yield session + app.dependency_overrides[get_db] = override_get_db + app.state.connection_manager = ConnectionManager() + app.state.token_denylist = {} + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + print("=== RBAC DEMO ===") + r = await c.post("/operator/login", json={"operator_id": "demo", "api_key": "dev-operator-key-change-in-production"}) + assert r.status_code == 200 + d = r.json() + print(f"Owner login: org={d['org_id']}, role={d['role']}") + owner_h = {"Authorization": f"Bearer {d['token']}"} + + r = await c.post("/orgs", json={"name": "Demo WS", "slug": "demo-ws"}, headers=owner_h) + assert r.status_code == 201 + org_id = r.json()["org_id"] + print(f"Created org: {org_id}") + + for op, role in [("alice", "admin"), ("bob", "operator"), ("carol", "viewer")]: + r = await c.post(f"/orgs/{org_id}/members", json={"operator_id": op, "role": role}, headers=owner_h) + assert r.status_code == 201 + print(f" Added {op} as {role}") + + # Admin login + r = await c.post("/operator/login", json={"operator_id": "alice", "api_key": "dev-operator-key-change-in-production"}) + admin_h = {"Authorization": f"Bearer {r.json()['token']}"} + print(f"\nAdmin alice: org={r.json()['org_id']}, role={r.json()['role']}") + r = await c.post(f"/orgs/{org_id}/invites", json={"role": "operator", "expires_hours": 24}, headers=admin_h) + assert r.status_code == 201 + invite_token = r.json()["invite_token"] + print(f" Created invite (token len: {len(invite_token)})") + + # Operator login + r = await c.post("/operator/login", json={"operator_id": "bob", "api_key": "dev-operator-key-change-in-production"}) + op_h = {"Authorization": f"Bearer {r.json()['token']}"} + print(f"\nOperator bob: org={r.json()['org_id']}, role={r.json()['role']}") + r = await c.post(f"/orgs/{org_id}/invites", json={"role": "viewer"}, headers=op_h) + assert r.status_code == 403 + print(" Create invite: BLOCKED (403)") + r = await c.post(f"/orgs/{org_id}/members", json={"operator_id": "x", "role": "viewer"}, headers=op_h) + assert r.status_code == 403 + print(" Add member: BLOCKED (403)") + + # Viewer login + r = await c.post("/operator/login", json={"operator_id": "carol", "api_key": "dev-operator-key-change-in-production"}) + viewer_h = {"Authorization": f"Bearer {r.json()['token']}"} + print(f"\nViewer carol: org={r.json()['org_id']}, role={r.json()['role']}") + r = await c.get(f"/orgs/{org_id}/members", headers=viewer_h) + assert r.status_code == 200 + print(f" View members: {len(r.json())} members") + r = await c.post(f"/orgs/{org_id}/members", json={"operator_id": "y", "role": "viewer"}, headers=viewer_h) + assert r.status_code == 403 + print(" Add member: BLOCKED (403)") + + print("\n=== INVITE DEMO ===") + r = await c.post("/operator/login", json={"operator_id": "eve", "api_key": "dev-operator-key-change-in-production"}) + eve_h = {"Authorization": f"Bearer {r.json()['token']}"} + r = await c.post("/orgs/invites/accept", json={"invite_token": invite_token, "operator_id": "eve"}, headers=eve_h) + assert r.status_code == 201 + print(f"Eve accepted invite: role={r.json()['role']}") + + print("\n=== TRUST-LINK DEMO ===") + r = await c.post(f"/orgs/{org_id}/trust-links", json={"node_a_id": "alpha", "node_b_id": "beta"}, headers=owner_h) + assert r.status_code == 201 + link_id = r.json()["link_id"] + print(f"Created: {r.json()['node_a_id']} -> {r.json()['node_b_id']}") + r = await c.post(f"/orgs/{org_id}/trust-links", json={"node_a_id": "alpha", "node_b_id": "alpha"}, headers=owner_h) + assert r.status_code == 400 + print("Self-link: BLOCKED (400)") + r = await c.post(f"/orgs/{org_id}/trust-links", json={"node_a_id": "alpha", "node_b_id": "beta"}, headers=owner_h) + assert r.status_code == 409 + print("Duplicate: BLOCKED (409)") + r = await c.post(f"/orgs/{org_id}/trust-links/{link_id}/revoke", headers=owner_h) + assert r.status_code == 200 + print(f"Revoked: status={r.json()['status']}") + + print("\n=== ORG ISOLATION ===") + r = await c.post("/operator/login", json={"operator_id": "mallory", "api_key": "dev-operator-key-change-in-production"}) + mall_h = {"Authorization": f"Bearer {r.json()['token']}"} + await c.post("/orgs", json={"name": "Evil", "slug": "evil"}, headers=mall_h) + r = await c.get(f"/orgs/{org_id}", headers=mall_h) + assert r.status_code == 403 + print("Cross-org view: BLOCKED (403)") + r = await c.get(f"/orgs/{org_id}/members", headers=mall_h) + assert r.status_code == 403 + print("Cross-org members: BLOCKED (403)") + + print("\n=== AUDIT TRAIL ===") + for action in ["org_created", "member_added", "invite_sent", "invite_accepted", "trust_link_created", "trust_link_revoked"]: + r = await c.get(f"/audit/events?action={action}", headers=owner_h) + assert r.status_code == 200 + count = len(r.json()["events"]) + print(f" {action}: {count} event(s)") + + print("\n=== ALL DEMO CHECKS PASSED ===") + + app.dependency_overrides.clear() + await engine.dispose() + +if __name__ == "__main__": + asyncio.run(demo()) diff --git a/apps/api/tests/test_auth_posture.py b/apps/api/tests/test_auth_posture.py new file mode 100644 index 0000000..081b97f --- /dev/null +++ b/apps/api/tests/test_auth_posture.py @@ -0,0 +1,147 @@ +""" +AgentLink API — auth posture tests for require_auth_for_reads setting + +Tests that: + - When require_auth_for_reads=True (production): unauthenticated reads return 401 + - When require_auth_for_reads=False (demo): unauthenticated reads return data + - Detail endpoints enforce org scoping when authenticated + - Task mutation endpoints enforce org scoping +""" + +import pytest +from httpx import AsyncClient +from app.settings import settings + + +# ── Helpers ────────────────────────────────────────────────────────────────── + +async def login(client: AsyncClient, operator_id: str = "test-op") -> dict: + resp = await client.post( + "/operator/login", + json={"operator_id": operator_id, "api_key": "dev-operator-key-change-in-production"}, + ) + assert resp.status_code == 200 + return {"Authorization": f"Bearer {resp.json()['token']}"} + + +async def enroll_node(client: AsyncClient, node_id: str = "test-node") -> str: + resp = await client.post("/nodes/enroll", json={ + "node_id": node_id, + "device_name": "Test Node", + "platform": "linux", + "version": "0.1.0", + "public_key": "test-key", + }) + assert resp.status_code == 201 + return resp.json()["enrollment_token"] + + +# ── Auth-required mode (production default) ────────────────────────────────── + + +@pytest.mark.asyncio +async def test_task_list_requires_auth_in_production_mode(client: AsyncClient): + """When require_auth_for_reads=True, unauthenticated GET /tasks returns 401.""" + original = settings.require_auth_for_reads + settings.require_auth_for_reads = True + try: + resp = await client.get("/tasks") + assert resp.status_code == 401 + finally: + settings.require_auth_for_reads = original + + +@pytest.mark.asyncio +async def test_node_list_requires_auth_in_production_mode(client: AsyncClient): + """When require_auth_for_reads=True, unauthenticated GET /nodes returns 401.""" + original = settings.require_auth_for_reads + settings.require_auth_for_reads = True + try: + resp = await client.get("/nodes") + assert resp.status_code == 401 + finally: + settings.require_auth_for_reads = original + + +@pytest.mark.asyncio +async def test_task_detail_requires_auth_in_production_mode(client: AsyncClient): + """When require_auth_for_reads=True, unauthenticated GET /tasks/{id} returns 401.""" + original = settings.require_auth_for_reads + settings.require_auth_for_reads = True + try: + resp = await client.get("/tasks/nonexistent") + assert resp.status_code == 401 + finally: + settings.require_auth_for_reads = original + + +@pytest.mark.asyncio +async def test_node_detail_requires_auth_in_production_mode(client: AsyncClient): + """When require_auth_for_reads=True, unauthenticated GET /nodes/{id} returns 401.""" + original = settings.require_auth_for_reads + settings.require_auth_for_reads = True + try: + resp = await client.get("/nodes/nonexistent") + assert resp.status_code == 401 + finally: + settings.require_auth_for_reads = original + + +@pytest.mark.asyncio +async def test_artifact_detail_requires_auth_in_production_mode(client: AsyncClient): + """When require_auth_for_reads=True, unauthenticated GET /artifacts/{id} returns 401.""" + original = settings.require_auth_for_reads + settings.require_auth_for_reads = True + try: + resp = await client.get("/artifacts/nonexistent") + assert resp.status_code == 401 + finally: + settings.require_auth_for_reads = original + + +# ── Demo mode ──────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_task_list_open_in_demo_mode(client: AsyncClient): + """When require_auth_for_reads=False, unauthenticated GET /tasks returns 200.""" + # conftest sets require_auth_for_reads=False by default + resp = await client.get("/tasks") + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_node_list_open_in_demo_mode(client: AsyncClient): + """When require_auth_for_reads=False, unauthenticated GET /nodes returns 200.""" + resp = await client.get("/nodes") + assert resp.status_code == 200 + + +# ── Org scoping on detail endpoints ────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_task_detail_nonexistent_returns_404(client: AsyncClient): + """Authenticated user gets 404 for nonexistent task.""" + h = await login(client, "detail-checker") + resp = await client.get("/tasks/nonexistent-task-id", headers=h) + assert resp.status_code == 404 + + +# ── Task mutation org scoping ──────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_approve_checks_task_org(client: AsyncClient): + """Approve endpoint returns 404 for tasks in a different org.""" + # This test verifies the _check_task_org function works. + # In demo mode, tasks without org_id pass the check (backward compat). + h = await login(client, "approve-checker") + + # Try to approve a nonexistent task + resp = await client.post( + "/tasks/nonexistent-task/approve", + json={"reason": "test"}, + headers=h, + ) + assert resp.status_code == 404 diff --git a/apps/api/tests/test_orgs.py b/apps/api/tests/test_orgs.py new file mode 100644 index 0000000..94ab9c2 --- /dev/null +++ b/apps/api/tests/test_orgs.py @@ -0,0 +1,413 @@ +""" +AgentLink API — tests for organizations, memberships, invites, and trust links (Phase 4) + +Covers: + - Organization CRUD + - Membership RBAC enforcement + - Invite creation, acceptance, revocation, expiration + - Trust link creation and revocation + - Audit events for org/membership/invite actions + - Operator login auto-enrollment in default org +""" + +import pytest +from httpx import AsyncClient + + +# ── Helpers ────────────────────────────────────────────────────────────────── + + +async def login(client: AsyncClient, operator_id: str = "test-operator") -> dict: + """Login and return auth headers.""" + resp = await client.post( + "/operator/login", + json={"operator_id": operator_id, "api_key": "dev-operator-key-change-in-production"}, + ) + assert resp.status_code == 200 + return {"Authorization": f"Bearer {resp.json()['token']}"} + + +async def login_response(client: AsyncClient, operator_id: str = "test-operator") -> dict: + """Login and return the full response body.""" + resp = await client.post( + "/operator/login", + json={"operator_id": operator_id, "api_key": "dev-operator-key-change-in-production"}, + ) + assert resp.status_code == 200 + return resp.json() + + +# ── Operator Login ─────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_login_returns_org_and_role(client: AsyncClient): + """Login response includes org_id and role.""" + data = await login_response(client, "alice") + assert "org_id" in data + assert "role" in data + assert data["org_id"] == "default" + assert data["role"] == "owner" + + +@pytest.mark.asyncio +async def test_login_auto_enrolls_in_default_org(client: AsyncClient): + """First login auto-creates default org and owner membership.""" + headers = await login(client, "new-operator") + resp = await client.get("/orgs", headers=headers) + assert resp.status_code == 200 + orgs = resp.json() + assert len(orgs) >= 1 + slugs = [o["slug"] for o in orgs] + assert "default" in slugs + + +@pytest.mark.asyncio +async def test_refresh_preserves_org_and_role(client: AsyncClient): + """Token refresh returns same org_id and role.""" + login_data = await login_response(client, "refresh-op") + resp = await client.post( + "/operator/refresh", + headers={"Authorization": f"Bearer {login_data['token']}"}, + ) + assert resp.status_code == 200 + refresh_data = resp.json() + assert refresh_data["org_id"] == login_data["org_id"] + assert refresh_data["role"] == login_data["role"] + + +# ── Organization CRUD ──────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_create_org(client: AsyncClient): + """Create a new organization.""" + headers = await login(client, "org-creator") + resp = await client.post( + "/orgs", + json={"name": "Test Org", "slug": "test-org", "description": "A test org"}, + headers=headers, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == "Test Org" + assert data["slug"] == "test-org" + + +@pytest.mark.asyncio +async def test_create_org_duplicate_slug_fails(client: AsyncClient): + """Duplicate slug returns 409.""" + headers = await login(client, "dup-creator") + await client.post( + "/orgs", + json={"name": "Org A", "slug": "dup-slug"}, + headers=headers, + ) + resp = await client.post( + "/orgs", + json={"name": "Org B", "slug": "dup-slug"}, + headers=headers, + ) + assert resp.status_code == 409 + + +@pytest.mark.asyncio +async def test_list_orgs_returns_only_member_orgs(client: AsyncClient): + """List orgs returns only orgs the operator belongs to.""" + h1 = await login(client, "owner-1") + await client.post("/orgs", json={"name": "Org1", "slug": "org-1"}, headers=h1) + + h2 = await login(client, "owner-2") + await client.post("/orgs", json={"name": "Org2", "slug": "org-2"}, headers=h2) + + # owner-1 should only see default + org-1 + resp = await client.get("/orgs", headers=h1) + slugs = [o["slug"] for o in resp.json()] + assert "org-1" in slugs + assert "org-2" not in slugs + + +@pytest.mark.asyncio +async def test_get_org_requires_membership(client: AsyncClient): + """Non-members cannot view org details.""" + h1 = await login(client, "org-owner") + create_resp = await client.post("/orgs", json={"name": "Private", "slug": "private-org"}, headers=h1) + org_id = create_resp.json()["org_id"] + + h2 = await login(client, "outsider") + resp = await client.get(f"/orgs/{org_id}", headers=h2) + assert resp.status_code == 403 + + +# ── Membership RBAC ────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_add_member(client: AsyncClient): + """Admin can add a member.""" + headers = await login(client, "mem-owner") + create_resp = await client.post("/orgs", json={"name": "MemOrg", "slug": "mem-org"}, headers=headers) + org_id = create_resp.json()["org_id"] + + resp = await client.post( + f"/orgs/{org_id}/members", + json={"operator_id": "new-member", "role": "operator"}, + headers=headers, + ) + assert resp.status_code == 201 + assert resp.json()["role"] == "operator" + + +@pytest.mark.asyncio +async def test_add_member_duplicate_fails(client: AsyncClient): + """Adding an existing member returns 409.""" + headers = await login(client, "dup-mem-owner") + create_resp = await client.post("/orgs", json={"name": "DupMemOrg", "slug": "dup-mem-org"}, headers=headers) + org_id = create_resp.json()["org_id"] + + await client.post(f"/orgs/{org_id}/members", json={"operator_id": "member-x", "role": "viewer"}, headers=headers) + resp = await client.post(f"/orgs/{org_id}/members", json={"operator_id": "member-x", "role": "operator"}, headers=headers) + assert resp.status_code == 409 + + +@pytest.mark.asyncio +async def test_viewer_cannot_add_member(client: AsyncClient): + """Viewer role cannot add members (requires admin+).""" + owner_h = await login(client, "rbac-owner") + create_resp = await client.post("/orgs", json={"name": "RbacOrg", "slug": "rbac-org"}, headers=owner_h) + org_id = create_resp.json()["org_id"] + + # Add a viewer + await client.post(f"/orgs/{org_id}/members", json={"operator_id": "viewer-1", "role": "viewer"}, headers=owner_h) + + viewer_h = await login(client, "viewer-1") + resp = await client.post(f"/orgs/{org_id}/members", json={"operator_id": "another", "role": "viewer"}, headers=viewer_h) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_update_member_role(client: AsyncClient): + """Admin can update a member's role.""" + headers = await login(client, "role-owner") + create_resp = await client.post("/orgs", json={"name": "RoleOrg", "slug": "role-org"}, headers=headers) + org_id = create_resp.json()["org_id"] + + await client.post(f"/orgs/{org_id}/members", json={"operator_id": "upgradee", "role": "viewer"}, headers=headers) + resp = await client.patch(f"/orgs/{org_id}/members/upgradee", json={"role": "admin"}, headers=headers) + assert resp.status_code == 200 + assert resp.json()["role"] == "admin" + + +@pytest.mark.asyncio +async def test_remove_member(client: AsyncClient): + """Admin can remove a member.""" + headers = await login(client, "rm-owner") + create_resp = await client.post("/orgs", json={"name": "RmOrg", "slug": "rm-org"}, headers=headers) + org_id = create_resp.json()["org_id"] + + await client.post(f"/orgs/{org_id}/members", json={"operator_id": "removee", "role": "operator"}, headers=headers) + resp = await client.delete(f"/orgs/{org_id}/members/removee", headers=headers) + assert resp.status_code == 204 + + +@pytest.mark.asyncio +async def test_cannot_remove_self(client: AsyncClient): + """Cannot remove yourself from an org.""" + headers = await login(client, "self-rm") + create_resp = await client.post("/orgs", json={"name": "SelfRm", "slug": "self-rm"}, headers=headers) + org_id = create_resp.json()["org_id"] + + resp = await client.delete(f"/orgs/{org_id}/members/self-rm", headers=headers) + assert resp.status_code == 400 + + +# ── Invites ────────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_create_and_accept_invite(client: AsyncClient): + """Full invite lifecycle: create, accept, verify membership.""" + owner_h = await login(client, "inv-owner") + create_resp = await client.post("/orgs", json={"name": "InvOrg", "slug": "inv-org"}, headers=owner_h) + org_id = create_resp.json()["org_id"] + + # Create invite + inv_resp = await client.post( + f"/orgs/{org_id}/invites", + json={"role": "operator", "expires_hours": 24}, + headers=owner_h, + ) + assert inv_resp.status_code == 201 + invite_token = inv_resp.json()["invite_token"] + assert len(invite_token) > 10 + + # Accept invite as different operator + joiner_h = await login(client, "joiner") + accept_resp = await client.post( + "/orgs/invites/accept", + json={"invite_token": invite_token, "operator_id": "joiner"}, + headers=joiner_h, + ) + assert accept_resp.status_code == 201 + assert accept_resp.json()["role"] == "operator" + + # Verify membership + members_resp = await client.get(f"/orgs/{org_id}/members", headers=joiner_h) + assert members_resp.status_code == 200 + operator_ids = [m["operator_id"] for m in members_resp.json()] + assert "joiner" in operator_ids + + +@pytest.mark.asyncio +async def test_accept_invalid_token_fails(client: AsyncClient): + """Accepting a non-existent token returns 404.""" + headers = await login(client, "bad-token-user") + resp = await client.post( + "/orgs/invites/accept", + json={"invite_token": "nonexistent-token-xyz", "operator_id": "bad-token-user"}, + headers=headers, + ) + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_revoke_invite(client: AsyncClient): + """Revoking an invite changes its status.""" + owner_h = await login(client, "revoke-owner") + create_resp = await client.post("/orgs", json={"name": "RevOrg", "slug": "rev-org"}, headers=owner_h) + org_id = create_resp.json()["org_id"] + + inv_resp = await client.post( + f"/orgs/{org_id}/invites", + json={"role": "viewer"}, + headers=owner_h, + ) + invite_id = inv_resp.json()["invite_id"] + + revoke_resp = await client.post(f"/orgs/{org_id}/invites/{invite_id}/revoke", headers=owner_h) + assert revoke_resp.status_code == 200 + assert revoke_resp.json()["status"] == "revoked" + + +@pytest.mark.asyncio +async def test_accept_revoked_invite_fails(client: AsyncClient): + """Cannot accept a revoked invite.""" + owner_h = await login(client, "rev-accept-owner") + create_resp = await client.post("/orgs", json={"name": "RevAccOrg", "slug": "rev-acc-org"}, headers=owner_h) + org_id = create_resp.json()["org_id"] + + inv_resp = await client.post(f"/orgs/{org_id}/invites", json={"role": "operator"}, headers=owner_h) + invite_id = inv_resp.json()["invite_id"] + invite_token = inv_resp.json()["invite_token"] + + await client.post(f"/orgs/{org_id}/invites/{invite_id}/revoke", headers=owner_h) + + joiner_h = await login(client, "rev-joiner") + accept_resp = await client.post( + "/orgs/invites/accept", + json={"invite_token": invite_token, "operator_id": "rev-joiner"}, + headers=joiner_h, + ) + assert accept_resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_list_invites(client: AsyncClient): + """List invites returns all invites for the org.""" + owner_h = await login(client, "list-inv-owner") + create_resp = await client.post("/orgs", json={"name": "ListInvOrg", "slug": "list-inv-org"}, headers=owner_h) + org_id = create_resp.json()["org_id"] + + await client.post(f"/orgs/{org_id}/invites", json={"role": "operator"}, headers=owner_h) + await client.post(f"/orgs/{org_id}/invites", json={"role": "viewer"}, headers=owner_h) + + resp = await client.get(f"/orgs/{org_id}/invites", headers=owner_h) + assert resp.status_code == 200 + assert len(resp.json()) == 2 + + +# ── Trust Links ────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_create_trust_link(client: AsyncClient): + """Admin can create a trust link between two nodes.""" + headers = await login(client, "tl-owner") + create_resp = await client.post("/orgs", json={"name": "TlOrg", "slug": "tl-org"}, headers=headers) + org_id = create_resp.json()["org_id"] + + resp = await client.post( + f"/orgs/{org_id}/trust-links", + json={"node_a_id": "node-a", "node_b_id": "node-b"}, + headers=headers, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["node_a_id"] == "node-a" + assert data["node_b_id"] == "node-b" + assert data["status"] == "active" + + +@pytest.mark.asyncio +async def test_trust_link_self_fails(client: AsyncClient): + """Cannot create trust link to self.""" + headers = await login(client, "tl-self-owner") + create_resp = await client.post("/orgs", json={"name": "TlSelf", "slug": "tl-self"}, headers=headers) + org_id = create_resp.json()["org_id"] + + resp = await client.post( + f"/orgs/{org_id}/trust-links", + json={"node_a_id": "same-node", "node_b_id": "same-node"}, + headers=headers, + ) + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_revoke_trust_link(client: AsyncClient): + """Revoking a trust link sets status to revoked.""" + headers = await login(client, "tl-revoke-owner") + create_resp = await client.post("/orgs", json={"name": "TlRev", "slug": "tl-rev"}, headers=headers) + org_id = create_resp.json()["org_id"] + + link_resp = await client.post( + f"/orgs/{org_id}/trust-links", + json={"node_a_id": "node-x", "node_b_id": "node-y"}, + headers=headers, + ) + link_id = link_resp.json()["link_id"] + + revoke_resp = await client.post(f"/orgs/{org_id}/trust-links/{link_id}/revoke", headers=headers) + assert revoke_resp.status_code == 200 + assert revoke_resp.json()["status"] == "revoked" + + +@pytest.mark.asyncio +async def test_list_trust_links(client: AsyncClient): + """List trust links returns links for the org.""" + headers = await login(client, "tl-list-owner") + create_resp = await client.post("/orgs", json={"name": "TlList", "slug": "tl-list"}, headers=headers) + org_id = create_resp.json()["org_id"] + + await client.post(f"/orgs/{org_id}/trust-links", json={"node_a_id": "n1", "node_b_id": "n2"}, headers=headers) + await client.post(f"/orgs/{org_id}/trust-links", json={"node_a_id": "n3", "node_b_id": "n4"}, headers=headers) + + resp = await client.get(f"/orgs/{org_id}/trust-links", headers=headers) + assert resp.status_code == 200 + assert len(resp.json()) == 2 + + +# ── Audit Trail ────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_org_actions_create_audit_events(client: AsyncClient): + """Org creation and member addition emit audit events.""" + headers = await login(client, "audit-owner") + await client.post("/orgs", json={"name": "AuditOrg", "slug": "audit-org"}, headers=headers) + + # Check audit log for org_created event + resp = await client.get("/audit/events?action=org_created", headers=headers) + assert resp.status_code == 200 + events = resp.json()["events"] + assert any(e["action"] == "org_created" for e in events) diff --git a/apps/api/tests/test_rbac_matrix.py b/apps/api/tests/test_rbac_matrix.py new file mode 100644 index 0000000..df25743 --- /dev/null +++ b/apps/api/tests/test_rbac_matrix.py @@ -0,0 +1,428 @@ +""" +AgentLink API — comprehensive RBAC matrix, org isolation, and edge-case tests + +Covers: + 1. RBAC role matrix: owner/admin/operator/viewer permissions + 2. Org isolation: cross-org data leakage prevention + 3. Invite edge cases: expired, revoked, already-accepted, duplicate membership + 4. Trust-link edge cases: duplicate active, self-link, cross-org + 5. Removed-member access revocation + 6. Tampered token claims +""" + +import pytest +from httpx import AsyncClient + + +# ── Helpers ────────────────────────────────────────────────────────────────── + + +async def login(client: AsyncClient, operator_id: str = "test-op") -> dict: + resp = await client.post( + "/operator/login", + json={"operator_id": operator_id, "api_key": "dev-operator-key-change-in-production"}, + ) + assert resp.status_code == 200, f"Login failed for {operator_id}: {resp.text}" + return {"Authorization": f"Bearer {resp.json()['token']}"} + + +async def create_org_with_members( + client: AsyncClient, + owner_id: str, + slug: str, + members: dict[str, str] | None = None, +) -> str: + """Create org as owner, add members. Returns org_id.""" + owner_h = await login(client, owner_id) + resp = await client.post( + "/orgs", + json={"name": f"Org {slug}", "slug": slug}, + headers=owner_h, + ) + assert resp.status_code == 201 + org_id = resp.json()["org_id"] + + if members: + for op_id, role in members.items(): + resp = await client.post( + f"/orgs/{org_id}/members", + json={"operator_id": op_id, "role": role}, + headers=owner_h, + ) + assert resp.status_code == 201, f"Failed adding {op_id}: {resp.text}" + return org_id + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 1. RBAC ROLE MATRIX +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestRBACMatrix: + """Verify the permission matrix for all four roles.""" + + @pytest.fixture(autouse=True) + async def setup_org(self, client: AsyncClient): + """Create an org with one member per role.""" + self.org_id = await create_org_with_members( + client, "rbac-owner", "rbac-matrix", + members={ + "rbac-admin": "admin", + "rbac-operator": "operator", + "rbac-viewer": "viewer", + }, + ) + self.owner_h = await login(client, "rbac-owner") + self.admin_h = await login(client, "rbac-admin") + self.operator_h = await login(client, "rbac-operator") + self.viewer_h = await login(client, "rbac-viewer") + + # ── Member management ──────────────────────────────────────────────── + + @pytest.mark.asyncio + async def test_owner_can_add_member(self, client: AsyncClient): + resp = await client.post( + f"/orgs/{self.org_id}/members", + json={"operator_id": "new-by-owner", "role": "viewer"}, + headers=self.owner_h, + ) + assert resp.status_code == 201 + + @pytest.mark.asyncio + async def test_admin_can_add_member(self, client: AsyncClient): + resp = await client.post( + f"/orgs/{self.org_id}/members", + json={"operator_id": "new-by-admin", "role": "viewer"}, + headers=self.admin_h, + ) + assert resp.status_code == 201 + + @pytest.mark.asyncio + async def test_operator_cannot_add_member(self, client: AsyncClient): + resp = await client.post( + f"/orgs/{self.org_id}/members", + json={"operator_id": "sneaky", "role": "viewer"}, + headers=self.operator_h, + ) + assert resp.status_code == 403 + + @pytest.mark.asyncio + async def test_viewer_cannot_add_member(self, client: AsyncClient): + resp = await client.post( + f"/orgs/{self.org_id}/members", + json={"operator_id": "sneaky", "role": "viewer"}, + headers=self.viewer_h, + ) + assert resp.status_code == 403 + + # ── Role update ────────────────────────────────────────────────────── + + @pytest.mark.asyncio + async def test_operator_cannot_update_role(self, client: AsyncClient): + resp = await client.patch( + f"/orgs/{self.org_id}/members/rbac-viewer", + json={"role": "admin"}, + headers=self.operator_h, + ) + assert resp.status_code == 403 + + @pytest.mark.asyncio + async def test_operator_cannot_escalate_self(self, client: AsyncClient): + """Operator cannot escalate own role to admin.""" + resp = await client.patch( + f"/orgs/{self.org_id}/members/rbac-operator", + json={"role": "admin"}, + headers=self.operator_h, + ) + assert resp.status_code == 403 + + # ── Invite management ──────────────────────────────────────────────── + + @pytest.mark.asyncio + async def test_admin_can_create_invite(self, client: AsyncClient): + resp = await client.post( + f"/orgs/{self.org_id}/invites", + json={"role": "operator", "expires_hours": 1}, + headers=self.admin_h, + ) + assert resp.status_code == 201 + + @pytest.mark.asyncio + async def test_operator_cannot_create_invite(self, client: AsyncClient): + resp = await client.post( + f"/orgs/{self.org_id}/invites", + json={"role": "viewer", "expires_hours": 1}, + headers=self.operator_h, + ) + assert resp.status_code == 403 + + @pytest.mark.asyncio + async def test_viewer_cannot_create_invite(self, client: AsyncClient): + resp = await client.post( + f"/orgs/{self.org_id}/invites", + json={"role": "viewer", "expires_hours": 1}, + headers=self.viewer_h, + ) + assert resp.status_code == 403 + + # ── Trust-link management ──────────────────────────────────────────── + + @pytest.mark.asyncio + async def test_admin_can_create_trust_link(self, client: AsyncClient): + resp = await client.post( + f"/orgs/{self.org_id}/trust-links", + json={"node_a_id": "rbac-n1", "node_b_id": "rbac-n2"}, + headers=self.admin_h, + ) + assert resp.status_code == 201 + + @pytest.mark.asyncio + async def test_operator_cannot_create_trust_link(self, client: AsyncClient): + resp = await client.post( + f"/orgs/{self.org_id}/trust-links", + json={"node_a_id": "n1", "node_b_id": "n2"}, + headers=self.operator_h, + ) + assert resp.status_code == 403 + + @pytest.mark.asyncio + async def test_viewer_cannot_create_trust_link(self, client: AsyncClient): + resp = await client.post( + f"/orgs/{self.org_id}/trust-links", + json={"node_a_id": "n1", "node_b_id": "n2"}, + headers=self.viewer_h, + ) + assert resp.status_code == 403 + + # ── Read access (all roles) ────────────────────────────────────────── + + @pytest.mark.asyncio + async def test_all_roles_can_view_members(self, client: AsyncClient): + for h in [self.owner_h, self.admin_h, self.operator_h, self.viewer_h]: + resp = await client.get(f"/orgs/{self.org_id}/members", headers=h) + assert resp.status_code == 200 + + @pytest.mark.asyncio + async def test_operator_can_view_trust_links(self, client: AsyncClient): + resp = await client.get(f"/orgs/{self.org_id}/trust-links", headers=self.operator_h) + assert resp.status_code == 200 + + @pytest.mark.asyncio + async def test_viewer_can_view_trust_links(self, client: AsyncClient): + """Viewer has at least operator-level read for trust links? Check actual min_role.""" + resp = await client.get(f"/orgs/{self.org_id}/trust-links", headers=self.viewer_h) + # Trust-link list requires operator+ — viewer should be blocked + assert resp.status_code == 403 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 2. ORG ISOLATION +# ═══════════════════════════════════════════════════════════════════════════════ + + +class TestOrgIsolation: + """Verify that org A's resources are invisible to org B members.""" + + @pytest.fixture(autouse=True) + async def setup_two_orgs(self, client: AsyncClient): + self.org_a = await create_org_with_members(client, "iso-owner-a", "iso-org-a") + self.org_b = await create_org_with_members(client, "iso-owner-b", "iso-org-b") + self.ha = await login(client, "iso-owner-a") + self.hb = await login(client, "iso-owner-b") + + @pytest.mark.asyncio + async def test_org_b_cannot_view_org_a_details(self, client: AsyncClient): + resp = await client.get(f"/orgs/{self.org_a}", headers=self.hb) + assert resp.status_code == 403 + + @pytest.mark.asyncio + async def test_org_b_cannot_list_org_a_members(self, client: AsyncClient): + resp = await client.get(f"/orgs/{self.org_a}/members", headers=self.hb) + assert resp.status_code == 403 + + @pytest.mark.asyncio + async def test_org_b_cannot_add_member_to_org_a(self, client: AsyncClient): + resp = await client.post( + f"/orgs/{self.org_a}/members", + json={"operator_id": "intruder", "role": "viewer"}, + headers=self.hb, + ) + assert resp.status_code == 403 + + @pytest.mark.asyncio + async def test_org_b_cannot_create_invite_in_org_a(self, client: AsyncClient): + resp = await client.post( + f"/orgs/{self.org_a}/invites", + json={"role": "viewer", "expires_hours": 1}, + headers=self.hb, + ) + assert resp.status_code == 403 + + @pytest.mark.asyncio + async def test_org_b_cannot_create_trust_link_in_org_a(self, client: AsyncClient): + resp = await client.post( + f"/orgs/{self.org_a}/trust-links", + json={"node_a_id": "x", "node_b_id": "y"}, + headers=self.hb, + ) + assert resp.status_code == 403 + + @pytest.mark.asyncio + async def test_org_list_only_shows_own_orgs(self, client: AsyncClient): + resp_a = await client.get("/orgs", headers=self.ha) + resp_b = await client.get("/orgs", headers=self.hb) + slugs_a = [o["slug"] for o in resp_a.json()] + slugs_b = [o["slug"] for o in resp_b.json()] + assert "iso-org-a" in slugs_a + assert "iso-org-b" not in slugs_a + assert "iso-org-b" in slugs_b + assert "iso-org-a" not in slugs_b + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 3. INVITE EDGE CASES +# ═══════════════════════════════════════════════════════════════════════════════ + + +@pytest.mark.asyncio +async def test_double_accept_invite_fails(client: AsyncClient): + """Accepting the same invite token twice should fail the second time.""" + owner_h = await login(client, "dbl-acc-owner") + resp = await client.post("/orgs", json={"name": "DblAcc", "slug": "dbl-acc"}, headers=owner_h) + org_id = resp.json()["org_id"] + + inv = await client.post(f"/orgs/{org_id}/invites", json={"role": "operator"}, headers=owner_h) + token = inv.json()["invite_token"] + + # First accept + joiner_h = await login(client, "dbl-joiner") + resp1 = await client.post( + "/orgs/invites/accept", + json={"invite_token": token, "operator_id": "dbl-joiner"}, + headers=joiner_h, + ) + assert resp1.status_code == 201 + + # Second accept (same token, different user) + joiner2_h = await login(client, "dbl-joiner-2") + resp2 = await client.post( + "/orgs/invites/accept", + json={"invite_token": token, "operator_id": "dbl-joiner-2"}, + headers=joiner2_h, + ) + # Should fail because invite is already accepted + assert resp2.status_code == 400 + + +@pytest.mark.asyncio +async def test_already_member_cannot_accept_invite(client: AsyncClient): + """If operator is already a member, accepting an invite should fail 409.""" + owner_h = await login(client, "dup-mem-owner-2") + resp = await client.post("/orgs", json={"name": "DupMem2", "slug": "dup-mem-2"}, headers=owner_h) + org_id = resp.json()["org_id"] + + # Add a member directly + await client.post( + f"/orgs/{org_id}/members", + json={"operator_id": "existing-mem", "role": "viewer"}, + headers=owner_h, + ) + + # Create invite + inv = await client.post(f"/orgs/{org_id}/invites", json={"role": "operator"}, headers=owner_h) + token = inv.json()["invite_token"] + + # Try to accept as existing member + mem_h = await login(client, "existing-mem") + resp = await client.post( + "/orgs/invites/accept", + json={"invite_token": token, "operator_id": "existing-mem"}, + headers=mem_h, + ) + assert resp.status_code == 409 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 4. TRUST-LINK EDGE CASES +# ═══════════════════════════════════════════════════════════════════════════════ + + +@pytest.mark.asyncio +async def test_duplicate_active_trust_link_fails(client: AsyncClient): + """Cannot create a second active trust link between the same nodes.""" + owner_h = await login(client, "dup-tl-owner") + resp = await client.post("/orgs", json={"name": "DupTL", "slug": "dup-tl"}, headers=owner_h) + org_id = resp.json()["org_id"] + + # Create first link + resp1 = await client.post( + f"/orgs/{org_id}/trust-links", + json={"node_a_id": "dup-n1", "node_b_id": "dup-n2"}, + headers=owner_h, + ) + assert resp1.status_code == 201 + + # Try to create duplicate + resp2 = await client.post( + f"/orgs/{org_id}/trust-links", + json={"node_a_id": "dup-n1", "node_b_id": "dup-n2"}, + headers=owner_h, + ) + assert resp2.status_code == 409 + + +@pytest.mark.asyncio +async def test_revoked_trust_link_allows_recreation(client: AsyncClient): + """After revoking a trust link, creating a new one should succeed.""" + owner_h = await login(client, "rev-recreate-owner") + resp = await client.post("/orgs", json={"name": "RevRecreate", "slug": "rev-recreate"}, headers=owner_h) + org_id = resp.json()["org_id"] + + # Create and revoke + link_resp = await client.post( + f"/orgs/{org_id}/trust-links", + json={"node_a_id": "rr-n1", "node_b_id": "rr-n2"}, + headers=owner_h, + ) + link_id = link_resp.json()["link_id"] + await client.post(f"/orgs/{org_id}/trust-links/{link_id}/revoke", headers=owner_h) + + # Recreate should succeed + resp2 = await client.post( + f"/orgs/{org_id}/trust-links", + json={"node_a_id": "rr-n1", "node_b_id": "rr-n2"}, + headers=owner_h, + ) + assert resp2.status_code == 201 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 5. REMOVED MEMBER ACCESS +# ═══════════════════════════════════════════════════════════════════════════════ + + +@pytest.mark.asyncio +async def test_removed_member_cannot_access_org(client: AsyncClient): + """After removal, a member's existing token should still fail org access checks.""" + owner_h = await login(client, "rm-access-owner") + resp = await client.post("/orgs", json={"name": "RmAccess", "slug": "rm-access"}, headers=owner_h) + org_id = resp.json()["org_id"] + + # Add and login as member + await client.post( + f"/orgs/{org_id}/members", + json={"operator_id": "rm-target", "role": "operator"}, + headers=owner_h, + ) + target_h = await login(client, "rm-target") + + # Verify access works + resp = await client.get(f"/orgs/{org_id}/members", headers=target_h) + assert resp.status_code == 200 + + # Remove the member + await client.delete(f"/orgs/{org_id}/members/rm-target", headers=owner_h) + + # Verify access is now denied + resp = await client.get(f"/orgs/{org_id}/members", headers=target_h) + assert resp.status_code == 403 diff --git a/apps/web/src/app/operator/login/page.tsx b/apps/web/src/app/operator/login/page.tsx index b2387e9..5f3fa09 100644 --- a/apps/web/src/app/operator/login/page.tsx +++ b/apps/web/src/app/operator/login/page.tsx @@ -15,7 +15,7 @@ import { useState, useEffect, FormEvent } from "react"; import { useOperatorSession } from "../../../hooks/useOperatorSession"; export default function OperatorLoginPage() { - const { isLoggedIn, operatorId, loginLoading, loginError, login, logout } = + const { isLoggedIn, operatorId, orgId, role, loginLoading, loginError, login, logout } = useOperatorSession(); const [operatorIdInput, setOperatorIdInput] = useState(""); @@ -44,7 +44,10 @@ export default function OperatorLoginPage() { return (

- Logged in as {operatorId}. Redirecting… + Logged in as {operatorId} + {orgId && <> in {orgId}} + {role && <> ({role})} + . Redirecting…

); diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 644875c..830af4b 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -5,14 +5,10 @@ * pending approvals badge, and top-level navigation. * * Implemented as a React Server Component — data fetched at render time. - * - * TODO (Phase 8): - * - Surface recent audit events on the home page (requires operator session) - * - Add operator-auth status indicator (link to /operator/login or show logged-in state) */ import { PROTOCOL_VERSION } from "@agentlink/protocol"; -import { fetchNodes, fetchTasks, checkApiHealth } from "../lib/api"; +import { fetchNodes, fetchTasks, checkApiHealth, fetchPendingApprovalCount } from "../lib/api"; // ── Sub-components ──────────────────────────────────────────────────────────── @@ -69,14 +65,15 @@ function StatCard({ // ── Page ────────────────────────────────────────────────────────────────────── export default async function DashboardPage() { - // Fetch in parallel — failures handled gracefully per field. - const [apiHealthy, nodes, tasks] = await Promise.all([ + const [apiHealthy, nodes, tasks, pendingApprovals] = await Promise.all([ checkApiHealth(), fetchNodes().catch(() => []), fetchTasks({ limit: 200 }).catch(() => ({ tasks: [], total: 0, limit: 200, offset: 0 })), + fetchPendingApprovalCount().catch(() => 0), ]); const onlineNodes = nodes.filter((n) => n.status === "online").length; + const offlineNodes = nodes.filter((n) => n.status === "offline").length; const activeTasks = tasks.tasks.filter( (t) => t.status === "pending" || t.status === "executing" ).length; @@ -93,7 +90,7 @@ export default async function DashboardPage() {

AgentLink

- Secure local-first network layer for AI agents, runtimes, and automations. + Secure local-first agent orchestration — auditable task routing, human approvals, multi-node execution.

@@ -129,9 +126,12 @@ export default async function DashboardPage() { > + {offlineNodes > 0 && ( + + )} - + @@ -146,12 +146,46 @@ export default async function DashboardPage() { > Nodes Tasks + Submit Task Approvals Audit Log Operator Login - Agents (Phase 3) - Settings (Phase 8) + + {/* Empty state guidance */} + {nodes.length === 0 && apiHealthy && ( +
+ Getting started +

+ No nodes are enrolled yet. Start a node daemon to register with the control plane: +

+
+            cd apps/node{"\n"}pnpm dev
+          
+

+ Then submit a task from the Submit Task page to see the full lifecycle. +

+
+ )} ); } diff --git a/apps/web/src/app/tasks/new/page.tsx b/apps/web/src/app/tasks/new/page.tsx index 60a20a7..860496c 100644 --- a/apps/web/src/app/tasks/new/page.tsx +++ b/apps/web/src/app/tasks/new/page.tsx @@ -4,16 +4,23 @@ * AgentLink Dashboard — /tasks/new * * Client-side form for submitting a new task to the control plane. - * Must be a client component because it uses React state + fetch. - * - * The form POSTs to POST /tasks via the createTask() API helper. - * On success, redirects to /tasks/{task_id}. - * On failure, displays the error from the control plane. + * Includes guidance for first-time users on task types, routing, and priority. */ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; -import { createTask } from "../../../lib/api"; +import { createTask, fetchNodes } from "../../../lib/api"; +import type { Node } from "@agentlink/protocol"; + +const COMMON_TASK_TYPES = [ + "research", + "code_generation", + "data_analysis", + "file_processing", + "browser_research", + "shell_tooling", + "image_generation", +]; export default function NewTaskPage() { const router = useRouter(); @@ -24,10 +31,19 @@ export default function NewTaskPage() { const [priority, setPriority] = useState(5); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); + const [nodes, setNodes] = useState([]); const INSTRUCTION_MAX = 8000; const instructionOver = instruction.length > INSTRUCTION_MAX; + useEffect(() => { + fetchNodes() + .then(setNodes) + .catch(() => setNodes([])); + }, []); + + const onlineNodes = nodes.filter((n) => n.status === "online"); + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (!taskType.trim() || !instruction.trim() || instructionOver) return; @@ -49,6 +65,9 @@ export default function NewTaskPage() { } } + const priorityLabel = + priority <= 2 ? "Low" : priority <= 4 ? "Normal" : priority <= 7 ? "High" : "Critical"; + return (
{/* Header */} @@ -58,7 +77,7 @@ export default function NewTaskPage() {

Submit Task

- Route a new task to an available node agent. + Route a new task to an available agent. The control plane will find the best match based on task type and node availability.

@@ -80,6 +99,23 @@ export default function NewTaskPage() { )} + {/* No online nodes warning */} + {onlineNodes.length === 0 && nodes.length > 0 && ( +
+ No nodes are currently online. Tasks will be queued and executed when a node comes online. +
+ )} + {/* Form */}
{ void handleSubmit(e); }}>
@@ -92,6 +128,7 @@ export default function NewTaskPage() { type="text" value={taskType} onChange={(e) => setTaskType(e.target.value)} + list="task-type-suggestions" placeholder="e.g. research, code_generation, data_analysis" required style={{ @@ -103,8 +140,13 @@ export default function NewTaskPage() { color: "var(--foreground)", }} /> + + {COMMON_TASK_TYPES.map((t) => ( + - Must match an agent's supported_task_categories for auto-routing. + Determines which agent handles the task. Must match an agent's supported categories for auto-routing. @@ -127,7 +169,7 @@ export default function NewTaskPage() {