201 lines
7.3 KiB
Python
201 lines
7.3 KiB
Python
"""OIDC integration tests for the global (default) policies routes.
|
|
|
|
The default-policies router (``/v1/policies``) gates purely on the
|
|
mode-agnostic ``permission_store.is_admin`` — reads require auth, writes
|
|
require admin — so it works under OIDC exactly as under accounts. These
|
|
tests pin that end-to-end: a real ``create_app`` wired with an OIDC
|
|
provider + permission store + policy store, driven by presenting an OIDC
|
|
session JWT as a Bearer token (the same mechanism ``test_oidc_admin_users``
|
|
uses).
|
|
|
|
Unlike Members (read-only under OIDC — no password actions), Policies is
|
|
fully functional: an OIDC admin can list, create, toggle, and delete.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import AsyncIterator
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
import pytest
|
|
import pytest_asyncio
|
|
from fastapi import FastAPI
|
|
|
|
from omnigent.runtime.agent_cache import AgentCache
|
|
from omnigent.server.app import create_app
|
|
from omnigent.server.auth import UnifiedAuthProvider
|
|
from omnigent.server.oidc import OIDCConfig, mint_session_cookie
|
|
from omnigent.stores.agent_store.sqlalchemy_store import SqlAlchemyAgentStore
|
|
from omnigent.stores.artifact_store.local import LocalArtifactStore
|
|
from omnigent.stores.comment_store.sqlalchemy_store import SqlAlchemyCommentStore
|
|
from omnigent.stores.conversation_store.sqlalchemy_store import (
|
|
SqlAlchemyConversationStore,
|
|
)
|
|
from omnigent.stores.file_store.sqlalchemy_store import SqlAlchemyFileStore
|
|
from omnigent.stores.permission_store.sqlalchemy_store import SqlAlchemyPermissionStore
|
|
from omnigent.stores.policy_store.sqlalchemy_store import SqlAlchemyPolicyStore
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
_TEST_SECRET = bytes.fromhex("aa" * 32)
|
|
_ADMIN = "admin@example.com"
|
|
_MEMBER = "member@example.com"
|
|
|
|
|
|
def _oidc_config() -> OIDCConfig:
|
|
"""Build a minimal GitHub-flavoured OIDCConfig for testing."""
|
|
return OIDCConfig(
|
|
issuer="https://github.com",
|
|
client_id="cid",
|
|
client_secret="secret",
|
|
redirect_uri="http://localhost:8000/auth/callback",
|
|
cookie_secret=_TEST_SECRET,
|
|
scopes="read:user user:email",
|
|
session_ttl_hours=8,
|
|
logout_redirect_uri=None,
|
|
allowed_domains=None,
|
|
provider_type="github",
|
|
authorization_endpoint="https://github.com/login/oauth/authorize",
|
|
token_endpoint="https://github.com/login/oauth/access_token",
|
|
jwks_uri=None,
|
|
userinfo_endpoint="https://api.github.com/user",
|
|
allow_invites=False,
|
|
)
|
|
|
|
|
|
def _bearer(user_id: str) -> dict[str, str]:
|
|
"""Authorization header carrying an OIDC session JWT for *user_id*."""
|
|
token = mint_session_cookie(
|
|
user_id=user_id,
|
|
cookie_secret=_TEST_SECRET,
|
|
ttl_hours=8,
|
|
provider="oidc",
|
|
)
|
|
return {"Authorization": f"Bearer {token}"}
|
|
|
|
|
|
@pytest.fixture()
|
|
def oidc_policy_app(runtime_init: None, db_uri: str, tmp_path: Path) -> FastAPI:
|
|
"""A create_app instance with OIDC auth + permission store + policy store.
|
|
|
|
Seeds an admin and a non-admin so the tests can drive both gating
|
|
paths. Header/accounts routers aren't mounted (no login_url match
|
|
needed) — the /v1/policies router only needs auth_provider +
|
|
permission_store, which is exactly the OIDC wiring.
|
|
"""
|
|
perm_store = SqlAlchemyPermissionStore(db_uri)
|
|
perm_store.ensure_user(_ADMIN, is_admin=True)
|
|
perm_store.ensure_user(_MEMBER)
|
|
|
|
artifact_store = LocalArtifactStore(str(tmp_path / "artifacts"))
|
|
auth_provider = UnifiedAuthProvider(source="oidc", oidc_config=_oidc_config())
|
|
return create_app(
|
|
agent_store=SqlAlchemyAgentStore(db_uri),
|
|
file_store=SqlAlchemyFileStore(db_uri),
|
|
conversation_store=SqlAlchemyConversationStore(db_uri),
|
|
artifact_store=artifact_store,
|
|
agent_cache=AgentCache(
|
|
artifact_store=artifact_store,
|
|
cache_dir=tmp_path / "cache",
|
|
),
|
|
comment_store=SqlAlchemyCommentStore(db_uri),
|
|
policy_store=SqlAlchemyPolicyStore(db_uri),
|
|
permission_store=perm_store,
|
|
auth_provider=auth_provider,
|
|
)
|
|
|
|
|
|
@pytest_asyncio.fixture()
|
|
async def oidc_policy_client(
|
|
oidc_policy_app: FastAPI,
|
|
) -> AsyncIterator[httpx.AsyncClient]:
|
|
"""HTTP client wired to the OIDC policy-enabled app."""
|
|
transport = httpx.ASGITransport(app=oidc_policy_app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
|
yield c
|
|
|
|
|
|
def _policy_payload(**overrides: object) -> dict:
|
|
"""Build a valid CreateDefaultPolicyRequest payload."""
|
|
base: dict = {
|
|
"name": "oidc_policy",
|
|
"type": "python",
|
|
"handler": "omnigent.policies.builtins.safety.ask_on_os_tools",
|
|
}
|
|
base.update(overrides) # type: ignore[arg-type]
|
|
return base
|
|
|
|
|
|
# ── Admin: full CRUD works under OIDC ─────────────────────────────────
|
|
|
|
|
|
async def test_oidc_admin_can_crud_default_policies(
|
|
oidc_policy_client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""An OIDC admin can create, list, toggle, and delete global policies."""
|
|
headers = _bearer(_ADMIN)
|
|
|
|
# Create.
|
|
create = await oidc_policy_client.post("/v1/policies", json=_policy_payload(), headers=headers)
|
|
assert create.status_code == 200, create.text
|
|
pid = create.json()["id"]
|
|
assert pid.startswith("pol_")
|
|
|
|
# List — the created policy is present.
|
|
listing = await oidc_policy_client.get("/v1/policies", headers=headers)
|
|
assert listing.status_code == 200
|
|
assert pid in [p["id"] for p in listing.json()["data"]]
|
|
|
|
# Toggle disabled.
|
|
patch = await oidc_policy_client.patch(
|
|
f"/v1/policies/{pid}", json={"enabled": False}, headers=headers
|
|
)
|
|
assert patch.status_code == 200
|
|
assert patch.json()["enabled"] is False
|
|
|
|
# Delete.
|
|
delete = await oidc_policy_client.delete(f"/v1/policies/{pid}", headers=headers)
|
|
assert delete.status_code == 200
|
|
assert delete.json()["deleted"] is True
|
|
|
|
|
|
# ── Auth gating ───────────────────────────────────────────────────────
|
|
|
|
|
|
async def test_oidc_unauthenticated_cannot_read_policies(
|
|
oidc_policy_client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""A request with no session is rejected (401) — reads require auth."""
|
|
resp = await oidc_policy_client.get("/v1/policies")
|
|
assert resp.status_code == 401
|
|
|
|
|
|
async def test_oidc_non_admin_can_read_but_not_write(
|
|
oidc_policy_client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""A non-admin OIDC user can list policies but cannot create them (403)."""
|
|
member = _bearer(_MEMBER)
|
|
|
|
# Reads are allowed for any authenticated user.
|
|
listing = await oidc_policy_client.get("/v1/policies", headers=member)
|
|
assert listing.status_code == 200
|
|
|
|
# Writes require admin — 403 for a member.
|
|
create = await oidc_policy_client.post("/v1/policies", json=_policy_payload(), headers=member)
|
|
assert create.status_code == 403
|
|
|
|
|
|
async def test_oidc_non_admin_cannot_delete(
|
|
oidc_policy_client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""A non-admin can't delete a policy an admin created (403)."""
|
|
pid = (
|
|
await oidc_policy_client.post(
|
|
"/v1/policies", json=_policy_payload(), headers=_bearer(_ADMIN)
|
|
)
|
|
).json()["id"]
|
|
|
|
resp = await oidc_policy_client.delete(f"/v1/policies/{pid}", headers=_bearer(_MEMBER))
|
|
assert resp.status_code == 403
|