From 5c3ba24e90482dd2b4340b6dc2368cb53cc1383d Mon Sep 17 00:00:00 2001 From: welsberr Date: Sat, 14 Mar 2026 13:29:55 -0400 Subject: [PATCH] Apply ZIP update: 145-didactopus-agent-audit-and-key-rotation-layer.zip [2026-03-14T13:19:23] --- pyproject.toml | 2 - src/didactopus/api.py | 259 ++++++++++++++++-------- src/didactopus/auth.py | 10 +- src/didactopus/config.py | 5 +- src/didactopus/models.py | 71 ++++++- src/didactopus/orm.py | 30 ++- src/didactopus/repository.py | 179 +++++++++++++---- src/didactopus/seed.py | 55 ++---- src/didactopus/worker.py | 17 +- webui/index.html | 2 +- webui/package.json | 16 +- webui/src/App.jsx | 367 ++++++++++------------------------- webui/src/api.js | 111 ++--------- webui/src/authStore.js | 19 +- webui/src/main.jsx | 1 - webui/src/styles.css | 31 +-- 16 files changed, 574 insertions(+), 601 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 17c47da..53c7bda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,11 +8,9 @@ version = "0.1.0" requires-python = ">=3.10" dependencies = [ "pydantic>=2.7", - "pyyaml>=6.0", "fastapi>=0.115", "uvicorn>=0.30", "sqlalchemy>=2.0", - "psycopg[binary]>=3.1", "passlib[bcrypt]>=1.7", "python-jose[cryptography]>=3.3" ] diff --git a/src/didactopus/api.py b/src/didactopus/api.py index 489d2af..ea39309 100644 --- a/src/didactopus/api.py +++ b/src/didactopus/api.py @@ -1,52 +1,102 @@ from __future__ import annotations +import json from fastapi import FastAPI, HTTPException, Header, Depends, BackgroundTasks from fastapi.middleware.cors import CORSMiddleware import uvicorn from .config import load_settings from .db import Base, engine -from .models import LoginRequest, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest +from .models import ( + LoginRequest, ServiceAccountLoginRequest, ServiceAccountCreateRequest, ServiceAccountRotateRequest, + ServiceAccountStateRequest, ServiceToken, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, + EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest, AgentCapabilityManifest, + AgentLearnerPlanRequest, AgentLearnerPlanResponse +) from .repository import ( - authenticate_user, get_user_by_id, store_refresh_token, refresh_token_active, revoke_refresh_token, - list_packs, list_pack_admin_rows, get_pack, upsert_pack, set_pack_publication, - create_learner, list_learners_for_user, learner_owned_by_user, load_learner_state, + authenticate_user, get_user_by_id, create_service_account, list_service_accounts, authenticate_service_account, + rotate_service_account_secret, set_service_account_active, add_agent_audit_log, list_agent_audit_logs, + store_refresh_token, refresh_token_active, revoke_refresh_token, deployment_policy_profile, list_packs_for_user, + get_pack, get_pack_row, upsert_pack, create_learner, learner_owned_by_user, load_learner_state, save_learner_state, create_evaluator_job, get_evaluator_job, list_evaluator_jobs_for_learner ) from .engine import apply_evidence, recommend_next -from .auth import issue_access_token, issue_refresh_token, decode_token, new_token_id +from .auth import issue_access_token, issue_refresh_token, issue_service_access_token, decode_token, new_token_id, new_secret from .worker import process_job settings = load_settings() Base.metadata.create_all(bind=engine) app = FastAPI(title="Didactopus API Prototype") -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"]) -def current_user(authorization: str = Header(default="")): +def current_actor(authorization: str = Header(default="")): token = authorization.removeprefix("Bearer ").strip() payload = decode_token(token) if token else None - if not payload or payload.get("kind") != "access": + if not payload: raise HTTPException(status_code=401, detail="Unauthorized") - user = get_user_by_id(int(payload["sub"])) - if user is None or not user.is_active: - raise HTTPException(status_code=401, detail="Unauthorized") - return user + if payload.get("kind") == "access": + user = get_user_by_id(int(payload["sub"])) + if user is None or not user.is_active: + raise HTTPException(status_code=401, detail="Unauthorized") + return {"actor_type": "user", "user": user, "scopes": None} + if payload.get("kind") == "service": + return { + "actor_type": "service", + "service_account_id": int(payload["sub"]), + "service_account_name": payload.get("service_account_name"), + "scopes": payload.get("scopes", []), + } + raise HTTPException(status_code=401, detail="Unauthorized") -def require_admin(user = Depends(current_user)): - if user.role != "admin": +def require_admin(actor = Depends(current_actor)): + if actor["actor_type"] != "user" or actor["user"].role != "admin": raise HTTPException(status_code=403, detail="Admin role required") - return user + return actor["user"] -def ensure_learner_access(user, learner_id: str): +def audit_service_action(actor, action: str, target: str, outcome: str = "ok", detail: dict | None = None): + if actor["actor_type"] == "service": + add_agent_audit_log( + actor["service_account_id"], + actor["service_account_name"], + action, + target, + outcome, + detail or {}, + ) + +def require_scope(scope: str): + def inner(actor = Depends(current_actor)): + if actor["actor_type"] == "user": + return actor + scopes = set(actor.get("scopes") or []) + if scope not in scopes: + audit_service_action(actor, f"scope_denied:{scope}", "", "denied", {"scope": scope}) + raise HTTPException(status_code=403, detail=f"Missing scope: {scope}") + return actor + return inner + +def ensure_learner_access(actor, learner_id: str): + if actor["actor_type"] == "service": + return + user = actor["user"] if user.role == "admin": return if not learner_owned_by_user(user.id, learner_id): - raise HTTPException(status_code=403, detail="Learner not accessible by this user") + raise HTTPException(status_code=403, detail="Learner not accessible by this actor") + +def ensure_pack_access(actor, pack_id: str): + row = get_pack_row(pack_id) + if row is None: + raise HTTPException(status_code=404, detail="Pack not found") + if actor["actor_type"] == "service": + return row + user = actor["user"] + if user.role == "admin": + return row + if row.policy_lane == "community": + return row + if row.owner_user_id == user.id: + return row + raise HTTPException(status_code=403, detail="Pack not accessible by this actor") @app.post("/api/login", response_model=TokenPair) def login(payload: LoginRequest): @@ -55,12 +105,15 @@ def login(payload: LoginRequest): raise HTTPException(status_code=401, detail="Invalid credentials") token_id = new_token_id() store_refresh_token(user.id, token_id) - return TokenPair( - access_token=issue_access_token(user.id, user.username, user.role), - refresh_token=issue_refresh_token(user.id, user.username, user.role, token_id), - username=user.username, - role=user.role, - ) + return TokenPair(access_token=issue_access_token(user.id, user.username, user.role), refresh_token=issue_refresh_token(user.id, user.username, user.role, token_id), username=user.username, role=user.role) + +@app.post("/api/service-accounts/login", response_model=ServiceToken) +def service_login(payload: ServiceAccountLoginRequest): + sa = authenticate_service_account(payload.name, payload.secret) + if sa is None: + raise HTTPException(status_code=401, detail="Invalid service account credentials") + scopes = json.loads(sa.scopes_json or "[]") + return ServiceToken(access_token=issue_service_access_token(sa.id, sa.name, scopes), service_account_name=sa.name, scopes=scopes) @app.post("/api/refresh", response_model=TokenPair) def refresh(payload: RefreshRequest): @@ -76,94 +129,140 @@ def refresh(payload: RefreshRequest): revoke_refresh_token(token_id) new_jti = new_token_id() store_refresh_token(user.id, new_jti) - return TokenPair( - access_token=issue_access_token(user.id, user.username, user.role), - refresh_token=issue_refresh_token(user.id, user.username, user.role, new_jti), - username=user.username, - role=user.role, - ) + return TokenPair(access_token=issue_access_token(user.id, user.username, user.role), refresh_token=issue_refresh_token(user.id, user.username, user.role, new_jti), username=user.username, role=user.role) + +@app.get("/api/deployment-policy") +def api_deployment_policy(actor = Depends(current_actor)): + return deployment_policy_profile().model_dump() + +@app.get("/api/agent/capabilities", response_model=AgentCapabilityManifest) +def api_agent_capabilities(actor = Depends(current_actor)): + return AgentCapabilityManifest() + +@app.post("/api/agent/learner-plan", response_model=AgentLearnerPlanResponse) +def api_agent_learner_plan(payload: AgentLearnerPlanRequest, actor = Depends(require_scope("recommendations:read"))): + ensure_learner_access(actor, payload.learner_id) + ensure_pack_access(actor, payload.pack_id) + state = load_learner_state(payload.learner_id) + pack = get_pack(payload.pack_id) + if pack is None: + raise HTTPException(status_code=404, detail="Pack not found") + cards = recommend_next(state, pack) + audit_service_action(actor, "agent_learner_plan", f"{payload.learner_id}:{payload.pack_id}", "ok", {"cards": len(cards)}) + return AgentLearnerPlanResponse(learner_id=payload.learner_id, pack_id=payload.pack_id, next_cards=cards, suggested_actions=["Read learner state", "Choose next card", "Submit evidence", "Refresh recommendations"]) + +@app.post("/api/admin/service-accounts") +def api_create_service_account(payload: ServiceAccountCreateRequest, user = Depends(require_admin)): + secret = new_secret() + sa = create_service_account(payload.name, user.id, payload.description, payload.scopes, secret) + return {"id": sa.id, "name": sa.name, "scopes": payload.scopes, "secret": secret} + +@app.get("/api/admin/service-accounts") +def api_list_service_accounts(user = Depends(require_admin)): + return list_service_accounts() + +@app.post("/api/admin/service-accounts/rotate") +def api_rotate_service_account(payload: ServiceAccountRotateRequest, user = Depends(require_admin)): + secret = new_secret() + sa = rotate_service_account_secret(payload.name, secret) + if sa is None: + raise HTTPException(status_code=404, detail="Service account not found") + return {"name": sa.name, "secret": secret} + +@app.post("/api/admin/service-accounts/state") +def api_service_account_state(payload: ServiceAccountStateRequest, name: str, user = Depends(require_admin)): + sa = set_service_account_active(name, payload.is_active) + if sa is None: + raise HTTPException(status_code=404, detail="Service account not found") + return {"name": sa.name, "is_active": sa.is_active} + +@app.get("/api/admin/agent-audit-logs") +def api_agent_audit_logs(user = Depends(require_admin)): + return list_agent_audit_logs() @app.get("/api/packs") -def api_list_packs(user = Depends(current_user)): - include_unpublished = user.role == "admin" - return [p.model_dump() for p in list_packs(include_unpublished=include_unpublished)] +def api_list_packs(actor = Depends(require_scope("packs:read"))): + user_id = actor["user"].id if actor["actor_type"] == "user" else None + packs = [p.model_dump() for p in list_packs_for_user(user_id, include_unpublished=(actor["actor_type"] == "user" and actor["user"].role == "admin"))] + audit_service_action(actor, "packs_list", "packs", "ok", {"count": len(packs)}) + return packs -@app.get("/api/admin/packs") -def api_admin_list_packs(user = Depends(require_admin)): - return list_pack_admin_rows() - -@app.post("/api/admin/packs") -def api_upsert_pack(payload: CreatePackRequest, user = Depends(require_admin)): - upsert_pack(payload.pack, is_published=payload.is_published) - return {"ok": True, "pack_id": payload.pack.id} - -@app.post("/api/admin/packs/{pack_id}/publish") -def api_publish_pack(pack_id: str, is_published: bool, user = Depends(require_admin)): - ok = set_pack_publication(pack_id, is_published) - if not ok: - raise HTTPException(status_code=404, detail="Pack not found") - return {"ok": True, "pack_id": pack_id, "is_published": is_published} +@app.post("/api/packs") +def api_upsert_personal_pack(payload: CreatePackRequest, actor = Depends(require_scope("packs:write_personal"))): + if payload.policy_lane != "personal": + raise HTTPException(status_code=403, detail="This endpoint is for personal-lane write access") + if actor["actor_type"] != "user": + raise HTTPException(status_code=403, detail="Service accounts may not own personal packs in this scaffold") + upsert_pack(payload.pack, submitted_by_user_id=actor["user"].id, policy_lane="personal", is_published=payload.is_published, change_summary=payload.change_summary) + return {"ok": True, "pack_id": payload.pack.id, "policy_lane": "personal"} @app.post("/api/learners") -def api_create_learner(payload: CreateLearnerRequest, user = Depends(current_user)): - create_learner(user.id, payload.learner_id, payload.display_name) +def api_create_learner(payload: CreateLearnerRequest, actor = Depends(require_scope("learners:write"))): + if actor["actor_type"] != "user": + raise HTTPException(status_code=403, detail="Service accounts do not create learners in this scaffold") + create_learner(actor["user"].id, payload.learner_id, payload.display_name) return {"ok": True, "learner_id": payload.learner_id} -@app.get("/api/learners") -def api_list_learners(user = Depends(current_user)): - return list_learners_for_user(user.id, is_admin=(user.role == "admin")) - @app.get("/api/learners/{learner_id}/state") -def api_get_learner_state(learner_id: str, user = Depends(current_user)): - ensure_learner_access(user, learner_id) - return load_learner_state(learner_id).model_dump() +def api_get_learner_state(learner_id: str, actor = Depends(require_scope("learners:read"))): + ensure_learner_access(actor, learner_id) + state = load_learner_state(learner_id).model_dump() + audit_service_action(actor, "learner_state_read", learner_id, "ok", {"records": len(state.get("records", []))}) + return state @app.put("/api/learners/{learner_id}/state") -def api_put_learner_state(learner_id: str, state: LearnerState, user = Depends(current_user)): - ensure_learner_access(user, learner_id) +def api_put_learner_state(learner_id: str, state: LearnerState, actor = Depends(require_scope("learners:write"))): + ensure_learner_access(actor, learner_id) if learner_id != state.learner_id: raise HTTPException(status_code=400, detail="Learner ID mismatch") - return save_learner_state(state).model_dump() + result = save_learner_state(state).model_dump() + audit_service_action(actor, "learner_state_write", learner_id, "ok", {"records": len(result.get("records", []))}) + return result @app.post("/api/learners/{learner_id}/evidence") -def api_post_evidence(learner_id: str, event: EvidenceEvent, user = Depends(current_user)): - ensure_learner_access(user, learner_id) +def api_post_evidence(learner_id: str, event: EvidenceEvent, actor = Depends(require_scope("learners:write"))): + ensure_learner_access(actor, learner_id) state = load_learner_state(learner_id) state = apply_evidence(state, event) - save_learner_state(state) - return state.model_dump() + result = save_learner_state(state).model_dump() + audit_service_action(actor, "learner_evidence_post", learner_id, "ok", {"concept_id": event.concept_id}) + return result @app.get("/api/learners/{learner_id}/recommendations/{pack_id}") -def api_get_recommendations(learner_id: str, pack_id: str, user = Depends(current_user)): - ensure_learner_access(user, learner_id) +def api_get_recommendations(learner_id: str, pack_id: str, actor = Depends(require_scope("recommendations:read"))): + ensure_learner_access(actor, learner_id) + ensure_pack_access(actor, pack_id) state = load_learner_state(learner_id) pack = get_pack(pack_id) if pack is None: raise HTTPException(status_code=404, detail="Pack not found") - return {"cards": recommend_next(state, pack)} + cards = recommend_next(state, pack) + audit_service_action(actor, "recommendations_read", f"{learner_id}:{pack_id}", "ok", {"cards": len(cards)}) + return {"cards": cards} @app.post("/api/learners/{learner_id}/evaluator-jobs", response_model=EvaluatorJobStatus) -def api_submit_evaluator_job(learner_id: str, payload: EvaluatorSubmission, background_tasks: BackgroundTasks, user = Depends(current_user)): - ensure_learner_access(user, learner_id) +def api_submit_evaluator_job(learner_id: str, payload: EvaluatorSubmission, background_tasks: BackgroundTasks, actor = Depends(require_scope("evaluators:submit"))): + ensure_learner_access(actor, learner_id) + ensure_pack_access(actor, payload.pack_id) job_id = create_evaluator_job(learner_id, payload.pack_id, payload.concept_id, payload.submitted_text) background_tasks.add_task(process_job, job_id) + audit_service_action(actor, "evaluator_job_submit", str(job_id), "ok", {"learner_id": learner_id, "pack_id": payload.pack_id}) return EvaluatorJobStatus(job_id=job_id, status="queued") @app.get("/api/evaluator-jobs/{job_id}", response_model=EvaluatorJobStatus) -def api_get_evaluator_job(job_id: int, user = Depends(current_user)): +def api_get_evaluator_job(job_id: int, actor = Depends(require_scope("evaluators:read"))): job = get_evaluator_job(job_id) if job is None: raise HTTPException(status_code=404, detail="Job not found") + audit_service_action(actor, "evaluator_job_read", str(job_id), "ok", {}) return EvaluatorJobStatus(job_id=job.id, status=job.status, result_score=job.result_score, result_confidence_hint=job.result_confidence_hint, result_notes=job.result_notes) @app.get("/api/learners/{learner_id}/evaluator-history") -def api_get_evaluator_history(learner_id: str, user = Depends(current_user)): - ensure_learner_access(user, learner_id) +def api_get_evaluator_history(learner_id: str, actor = Depends(require_scope("evaluators:read"))): + ensure_learner_access(actor, learner_id) jobs = list_evaluator_jobs_for_learner(learner_id) + audit_service_action(actor, "evaluator_history_read", learner_id, "ok", {"jobs": len(jobs)}) return [{"job_id": j.id, "status": j.status, "concept_id": j.concept_id, "result_score": j.result_score, "result_confidence_hint": j.result_confidence_hint, "result_notes": j.result_notes} for j in jobs] def main(): uvicorn.run(app, host=settings.host, port=settings.port) - -if __name__ == "__main__": - main() diff --git a/src/didactopus/auth.py b/src/didactopus/auth.py index a2d088d..c7b3117 100644 --- a/src/didactopus/auth.py +++ b/src/didactopus/auth.py @@ -20,10 +20,13 @@ def _encode_token(payload: dict, expires_delta: timedelta) -> str: return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm) def issue_access_token(user_id: int, username: str, role: str) -> str: - return _encode_token({"sub": str(user_id), "username": username, "role": role, "kind": "access"}, timedelta(minutes=settings.access_token_minutes)) + return _encode_token({"sub": str(user_id), "username": username, "role": role, "kind": "access"}, timedelta(minutes=30)) def issue_refresh_token(user_id: int, username: str, role: str, token_id: str) -> str: - return _encode_token({"sub": str(user_id), "username": username, "role": role, "kind": "refresh", "jti": token_id}, timedelta(days=settings.refresh_token_days)) + return _encode_token({"sub": str(user_id), "username": username, "role": role, "kind": "refresh", "jti": token_id}, timedelta(days=14)) + +def issue_service_access_token(service_account_id: int, name: str, scopes: list[str]) -> str: + return _encode_token({"sub": str(service_account_id), "service_account_name": name, "kind": "service", "scopes": scopes}, timedelta(hours=8)) def decode_token(token: str) -> dict | None: try: @@ -33,3 +36,6 @@ def decode_token(token: str) -> dict | None: def new_token_id() -> str: return secrets.token_urlsafe(24) + +def new_secret() -> str: + return secrets.token_urlsafe(24) diff --git a/src/didactopus/config.py b/src/didactopus/config.py index 9890f28..88e5551 100644 --- a/src/didactopus/config.py +++ b/src/didactopus/config.py @@ -3,13 +3,12 @@ import os from pydantic import BaseModel class Settings(BaseModel): - database_url: str = os.getenv("DIDACTOPUS_DATABASE_URL", "postgresql+psycopg://didactopus:didactopus-dev-password@localhost:5432/didactopus") + database_url: str = os.getenv("DIDACTOPUS_DATABASE_URL", "sqlite+pysqlite:///:memory:") host: str = os.getenv("DIDACTOPUS_HOST", "127.0.0.1") port: int = int(os.getenv("DIDACTOPUS_PORT", "8011")) jwt_secret: str = os.getenv("DIDACTOPUS_JWT_SECRET", "change-me") jwt_algorithm: str = "HS256" - access_token_minutes: int = 30 - refresh_token_days: int = 14 + deployment_policy_profile: str = os.getenv("DIDACTOPUS_POLICY_PROFILE", "single_user") def load_settings() -> Settings: return Settings() diff --git a/src/didactopus/models.py b/src/didactopus/models.py index e2a0b8b..4462baa 100644 --- a/src/didactopus/models.py +++ b/src/didactopus/models.py @@ -3,6 +3,7 @@ from pydantic import BaseModel, Field from typing import Literal EvidenceKind = Literal["checkpoint", "project", "exercise", "review"] +PolicyLane = Literal["personal", "community"] class TokenPair(BaseModel): access_token: str @@ -11,13 +12,57 @@ class TokenPair(BaseModel): username: str role: str +class ServiceToken(BaseModel): + access_token: str + token_type: str = "bearer" + service_account_name: str + scopes: list[str] + class LoginRequest(BaseModel): username: str password: str +class ServiceAccountLoginRequest(BaseModel): + name: str + secret: str + +class ServiceAccountCreateRequest(BaseModel): + name: str + description: str = "" + scopes: list[str] = Field(default_factory=list) + +class ServiceAccountRotateRequest(BaseModel): + name: str + +class ServiceAccountStateRequest(BaseModel): + is_active: bool + class RefreshRequest(BaseModel): refresh_token: str +class DeploymentPolicyProfile(BaseModel): + profile_name: str + default_personal_lane_enabled: bool = True + default_community_lane_enabled: bool = True + community_publish_requires_approval: bool = True + personal_publish_direct: bool = True + reviewer_assignment_required: bool = False + description: str = "" + +class AgentCapabilityManifest(BaseModel): + supports_pack_listing: bool = True + supports_pack_write_personal: bool = True + supports_pack_submit_community: bool = True + supports_recommendations: bool = True + supports_learner_state_read: bool = True + supports_learner_state_write: bool = True + supports_evaluator_jobs: bool = True + supports_governance_endpoints: bool = True + supports_review_queue: bool = True + supports_service_accounts: bool = True + supports_agent_audit_logs: bool = True + supports_service_account_rotation: bool = True + class PackConcept(BaseModel): id: str title: str @@ -41,6 +86,16 @@ class PackData(BaseModel): onboarding: dict = Field(default_factory=dict) compliance: PackCompliance = Field(default_factory=PackCompliance) +class CreatePackRequest(BaseModel): + pack: PackData + policy_lane: PolicyLane = "personal" + is_published: bool = False + change_summary: str = "" + +class CreateLearnerRequest(BaseModel): + learner_id: str + display_name: str = "" + class MasteryRecord(BaseModel): concept_id: str dimension: str @@ -63,10 +118,6 @@ class LearnerState(BaseModel): records: list[MasteryRecord] = Field(default_factory=list) history: list[EvidenceEvent] = Field(default_factory=list) -class CreateLearnerRequest(BaseModel): - learner_id: str - display_name: str = "" - class EvaluatorSubmission(BaseModel): pack_id: str concept_id: str @@ -80,6 +131,12 @@ class EvaluatorJobStatus(BaseModel): result_confidence_hint: float | None = None result_notes: str = "" -class CreatePackRequest(BaseModel): - pack: PackData - is_published: bool = True +class AgentLearnerPlanRequest(BaseModel): + learner_id: str + pack_id: str + +class AgentLearnerPlanResponse(BaseModel): + learner_id: str + pack_id: str + next_cards: list[dict] = Field(default_factory=list) + suggested_actions: list[str] = Field(default_factory=list) diff --git a/src/didactopus/orm.py b/src/didactopus/orm.py index d4a16f7..4abc0ed 100644 --- a/src/didactopus/orm.py +++ b/src/didactopus/orm.py @@ -10,6 +10,27 @@ class UserORM(Base): role: Mapped[str] = mapped_column(String(50), default="learner") is_active: Mapped[bool] = mapped_column(Boolean, default=True) +class ServiceAccountORM(Base): + __tablename__ = "service_accounts" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(120), unique=True, index=True) + owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + description: Mapped[str] = mapped_column(Text, default="") + scopes_json: Mapped[str] = mapped_column(Text, default="[]") + secret_hash: Mapped[str] = mapped_column(String(255)) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + +class AgentAuditLogORM(Base): + __tablename__ = "agent_audit_logs" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + service_account_id: Mapped[int] = mapped_column(ForeignKey("service_accounts.id"), index=True) + service_account_name: Mapped[str] = mapped_column(String(120), index=True) + action: Mapped[str] = mapped_column(String(120), index=True) + target: Mapped[str] = mapped_column(String(255), default="") + outcome: Mapped[str] = mapped_column(String(50), default="ok") + detail_json: Mapped[str] = mapped_column(Text, default="{}") + created_at: Mapped[str] = mapped_column(String(100), default="") + class RefreshTokenORM(Base): __tablename__ = "refresh_tokens" id: Mapped[int] = mapped_column(Integer, primary_key=True) @@ -20,11 +41,17 @@ class RefreshTokenORM(Base): class PackORM(Base): __tablename__ = "packs" id: Mapped[str] = mapped_column(String(100), primary_key=True) + owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + policy_lane: Mapped[str] = mapped_column(String(50), default="personal") title: Mapped[str] = mapped_column(String(255)) subtitle: Mapped[str] = mapped_column(Text, default="") level: Mapped[str] = mapped_column(String(100), default="novice-friendly") data_json: Mapped[str] = mapped_column(Text) - is_published: Mapped[bool] = mapped_column(Boolean, default=True) + validation_json: Mapped[str] = mapped_column(Text, default="{}") + provenance_json: Mapped[str] = mapped_column(Text, default="{}") + governance_state: Mapped[str] = mapped_column(String(50), default="draft") + current_version: Mapped[int] = mapped_column(Integer, default=1) + is_published: Mapped[bool] = mapped_column(Boolean, default=False) class LearnerORM(Base): __tablename__ = "learners" @@ -66,3 +93,4 @@ class EvaluatorJobORM(Base): result_score: Mapped[float | None] = mapped_column(Float, nullable=True) result_confidence_hint: Mapped[float | None] = mapped_column(Float, nullable=True) result_notes: Mapped[str] = mapped_column(Text, default="") + trace_json: Mapped[str] = mapped_column(Text, default="{}") diff --git a/src/didactopus/repository.py b/src/didactopus/repository.py index 23b55a6..fb8fc6a 100644 --- a/src/didactopus/repository.py +++ b/src/didactopus/repository.py @@ -1,10 +1,28 @@ from __future__ import annotations import json +from datetime import datetime, timezone from sqlalchemy import select from .db import SessionLocal -from .orm import UserORM, RefreshTokenORM, PackORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, EvaluatorJobORM -from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent -from .auth import verify_password +from .orm import UserORM, ServiceAccountORM, AgentAuditLogORM, RefreshTokenORM, PackORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, EvaluatorJobORM +from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent, DeploymentPolicyProfile +from .auth import verify_password, hash_password +from .config import load_settings + +settings = load_settings() + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + +def deployment_policy_profile() -> DeploymentPolicyProfile: + return DeploymentPolicyProfile( + profile_name=settings.deployment_policy_profile, + default_personal_lane_enabled=True, + default_community_lane_enabled=True, + community_publish_requires_approval=True, + personal_publish_direct=True, + reviewer_assignment_required=False, + description="Deployment policy scaffold." + ) def get_user_by_username(username: str): with SessionLocal() as db: @@ -20,6 +38,83 @@ def authenticate_user(username: str, password: str): return None return user +def create_service_account(name: str, owner_user_id: int | None, description: str, scopes: list[str], secret: str): + with SessionLocal() as db: + sa = ServiceAccountORM( + name=name, + owner_user_id=owner_user_id, + description=description, + scopes_json=json.dumps(scopes), + secret_hash=hash_password(secret), + is_active=True, + ) + db.add(sa) + db.commit() + db.refresh(sa) + return sa + +def list_service_accounts(): + with SessionLocal() as db: + rows = db.execute(select(ServiceAccountORM).order_by(ServiceAccountORM.id)).scalars().all() + return [{"id": r.id, "name": r.name, "owner_user_id": r.owner_user_id, "description": r.description, "scopes": json.loads(r.scopes_json or "[]"), "is_active": r.is_active} for r in rows] + +def get_service_account_by_name(name: str): + with SessionLocal() as db: + return db.execute(select(ServiceAccountORM).where(ServiceAccountORM.name == name)).scalar_one_or_none() + +def authenticate_service_account(name: str, secret: str): + sa = get_service_account_by_name(name) + if sa is None or not sa.is_active or not verify_password(secret, sa.secret_hash): + return None + return sa + +def rotate_service_account_secret(name: str, new_secret: str): + with SessionLocal() as db: + sa = db.execute(select(ServiceAccountORM).where(ServiceAccountORM.name == name)).scalar_one_or_none() + if sa is None: + return None + sa.secret_hash = hash_password(new_secret) + db.commit() + db.refresh(sa) + return sa + +def set_service_account_active(name: str, is_active: bool): + with SessionLocal() as db: + sa = db.execute(select(ServiceAccountORM).where(ServiceAccountORM.name == name)).scalar_one_or_none() + if sa is None: + return None + sa.is_active = is_active + db.commit() + db.refresh(sa) + return sa + +def add_agent_audit_log(service_account_id: int, service_account_name: str, action: str, target: str, outcome: str, detail: dict): + with SessionLocal() as db: + db.add(AgentAuditLogORM( + service_account_id=service_account_id, + service_account_name=service_account_name, + action=action, + target=target, + outcome=outcome, + detail_json=json.dumps(detail), + created_at=now_iso(), + )) + db.commit() + +def list_agent_audit_logs(limit: int = 200): + with SessionLocal() as db: + rows = db.execute(select(AgentAuditLogORM).order_by(AgentAuditLogORM.id.desc())).scalars().all()[:limit] + return [{ + "id": r.id, + "service_account_id": r.service_account_id, + "service_account_name": r.service_account_name, + "action": r.action, + "target": r.target, + "outcome": r.outcome, + "detail": json.loads(r.detail_json or "{}"), + "created_at": r.created_at, + } for r in rows] + def store_refresh_token(user_id: int, token_id: str): with SessionLocal() as db: db.add(RefreshTokenORM(user_id=user_id, token_id=token_id, is_revoked=False)) @@ -37,66 +132,77 @@ def revoke_refresh_token(token_id: str): row.is_revoked = True db.commit() -def list_packs(include_unpublished: bool = False) -> list[PackData]: +def list_packs_for_user(user_id: int | None = None, include_unpublished: bool = False): with SessionLocal() as db: stmt = select(PackORM) if not include_unpublished: stmt = stmt.where(PackORM.is_published == True) - return [PackData.model_validate(json.loads(r.data_json)) for r in db.execute(stmt).scalars().all()] - -def list_pack_admin_rows(): - with SessionLocal() as db: - rows = db.execute(select(PackORM).order_by(PackORM.id)).scalars().all() - return [{"id": r.id, "title": r.title, "is_published": r.is_published, "subtitle": r.subtitle} for r in rows] + rows = db.execute(stmt).scalars().all() + out = [] + for r in rows: + if r.policy_lane == "community": + out.append(PackData.model_validate(json.loads(r.data_json))) + elif user_id is not None and r.owner_user_id == user_id: + out.append(PackData.model_validate(json.loads(r.data_json))) + return out def get_pack(pack_id: str): with SessionLocal() as db: row = db.get(PackORM, pack_id) return None if row is None else PackData.model_validate(json.loads(row.data_json)) -def upsert_pack(pack: PackData, is_published: bool = True): +def get_pack_row(pack_id: str): + with SessionLocal() as db: + return db.get(PackORM, pack_id) + +def upsert_pack(pack: PackData, submitted_by_user_id: int, policy_lane: str = "personal", is_published: bool = False, change_summary: str = ""): + validation = {"ok": len(pack.concepts) > 0, "warnings": [] if len(pack.concepts) > 0 else ["Pack has no concepts."], "errors": []} + provenance = {"source_count": pack.compliance.sources, "restrictive_flags": list(pack.compliance.flags)} with SessionLocal() as db: row = db.get(PackORM, pack.id) payload = json.dumps(pack.model_dump()) if row is None: - db.add(PackORM(id=pack.id, title=pack.title, subtitle=pack.subtitle, level=pack.level, data_json=payload, is_published=is_published)) + row = PackORM( + id=pack.id, + owner_user_id=submitted_by_user_id if policy_lane == "personal" else None, + policy_lane=policy_lane, + title=pack.title, + subtitle=pack.subtitle, + level=pack.level, + data_json=payload, + validation_json=json.dumps(validation), + provenance_json=json.dumps(provenance), + governance_state="personal_ready" if policy_lane == "personal" else "draft", + current_version=1, + is_published=is_published if policy_lane == "personal" else False, + ) + db.add(row) else: + row.owner_user_id = submitted_by_user_id if policy_lane == "personal" else row.owner_user_id + row.policy_lane = policy_lane row.title = pack.title row.subtitle = pack.subtitle row.level = pack.level row.data_json = payload - row.is_published = is_published + row.validation_json = json.dumps(validation) + row.provenance_json = json.dumps(provenance) + row.current_version += 1 + if policy_lane == "personal": + row.is_published = is_published db.commit() -def set_pack_publication(pack_id: str, is_published: bool): - with SessionLocal() as db: - row = db.get(PackORM, pack_id) - if row is None: - return False - row.is_published = is_published - db.commit() - return True - def create_learner(owner_user_id: int, learner_id: str, display_name: str = ""): with SessionLocal() as db: if db.get(LearnerORM, learner_id) is None: db.add(LearnerORM(id=learner_id, owner_user_id=owner_user_id, display_name=display_name)) db.commit() -def list_learners_for_user(user_id: int, is_admin: bool = False): - with SessionLocal() as db: - stmt = select(LearnerORM).order_by(LearnerORM.id) - if not is_admin: - stmt = stmt.where(LearnerORM.owner_user_id == user_id) - rows = db.execute(stmt).scalars().all() - return [{"learner_id": r.id, "display_name": r.display_name, "owner_user_id": r.owner_user_id} for r in rows] - def learner_owned_by_user(user_id: int, learner_id: str) -> bool: with SessionLocal() as db: learner = db.get(LearnerORM, learner_id) return learner is not None and learner.owner_user_id == user_id -def load_learner_state(learner_id: str) -> LearnerState: +def load_learner_state(learner_id: str): with SessionLocal() as db: records = db.execute(select(MasteryRecordORM).where(MasteryRecordORM.learner_id == learner_id)).scalars().all() history = db.execute(select(EvidenceEventORM).where(EvidenceEventORM.learner_id == learner_id)).scalars().all() @@ -117,9 +223,9 @@ def save_learner_state(state: LearnerState): db.commit() return state -def create_evaluator_job(learner_id: str, pack_id: str, concept_id: str, submitted_text: str) -> int: +def create_evaluator_job(learner_id: str, pack_id: str, concept_id: str, submitted_text: str): with SessionLocal() as db: - job = EvaluatorJobORM(learner_id=learner_id, pack_id=pack_id, concept_id=concept_id, submitted_text=submitted_text, status="queued") + job = EvaluatorJobORM(learner_id=learner_id, pack_id=pack_id, concept_id=concept_id, submitted_text=submitted_text, status="queued", trace_json=json.dumps({"notes": ["Job queued"]})) db.add(job) db.commit() db.refresh(job) @@ -127,14 +233,13 @@ def create_evaluator_job(learner_id: str, pack_id: str, concept_id: str, submitt def list_evaluator_jobs_for_learner(learner_id: str): with SessionLocal() as db: - rows = db.execute(select(EvaluatorJobORM).where(EvaluatorJobORM.learner_id == learner_id).order_by(EvaluatorJobORM.id.desc())).scalars().all() - return rows + return db.execute(select(EvaluatorJobORM).where(EvaluatorJobORM.learner_id == learner_id).order_by(EvaluatorJobORM.id.desc())).scalars().all() def get_evaluator_job(job_id: int): with SessionLocal() as db: return db.get(EvaluatorJobORM, job_id) -def update_evaluator_job(job_id: int, status: str, score: float | None = None, confidence_hint: float | None = None, notes: str = ""): +def update_evaluator_job(job_id: int, status: str, score: float | None = None, confidence_hint: float | None = None, notes: str = "", trace: dict | None = None): with SessionLocal() as db: job = db.get(EvaluatorJobORM, job_id) if job is None: @@ -143,4 +248,6 @@ def update_evaluator_job(job_id: int, status: str, score: float | None = None, c job.result_score = score job.result_confidence_hint = confidence_hint job.result_notes = notes + if trace is not None: + job.trace_json = json.dumps(trace) db.commit() diff --git a/src/didactopus/seed.py b/src/didactopus/seed.py index 03bbc74..4b29cbd 100644 --- a/src/didactopus/seed.py +++ b/src/didactopus/seed.py @@ -1,48 +1,29 @@ from __future__ import annotations -import json from sqlalchemy import select from .db import Base, engine, SessionLocal -from .orm import UserORM, PackORM +from .orm import UserORM from .auth import hash_password - -PACKS = [ - { - "id": "bayes-pack", - "title": "Bayesian Reasoning", - "subtitle": "Probability, evidence, updating, and model criticism.", - "level": "novice-friendly", - "concepts": [ - {"id": "prior", "title": "Prior", "prerequisites": [], "masteryDimension": "mastery", "exerciseReward": "Prior badge earned"}, - {"id": "posterior", "title": "Posterior", "prerequisites": ["prior"], "masteryDimension": "mastery", "exerciseReward": "Posterior path opened"}, - {"id": "model-checking", "title": "Model Checking", "prerequisites": ["posterior"], "masteryDimension": "mastery", "exerciseReward": "Model-checking unlocked"} - ], - "onboarding": {"headline": "Start with a fast visible win", "body": "Read one short orientation, answer one guided question, and leave with your first mastery marker.", "checklist": ["Read the one-screen topic orientation", "Answer one guided exercise", "Write one explanation in your own words"]}, - "compliance": {"sources": 2, "attributionRequired": True, "shareAlikeRequired": True, "noncommercialOnly": True, "flags": ["share-alike", "noncommercial", "excluded-third-party-content"]} - }, - { - "id": "stats-pack", - "title": "Introductory Statistics", - "subtitle": "Descriptive statistics, sampling, and inference.", - "level": "novice-friendly", - "concepts": [ - {"id": "descriptive", "title": "Descriptive Statistics", "prerequisites": [], "masteryDimension": "mastery", "exerciseReward": "Descriptive tools unlocked"}, - {"id": "sampling", "title": "Sampling", "prerequisites": ["descriptive"], "masteryDimension": "mastery", "exerciseReward": "Sampling pathway opened"} - ], - "onboarding": {"headline": "Build your first useful data skill", "body": "You will learn one concept that immediately helps you summarize real data.", "checklist": ["See one worked example", "Compute one short example yourself", "Explain what the result means"]}, - "compliance": {"sources": 1, "attributionRequired": True, "shareAlikeRequired": False, "noncommercialOnly": False, "flags": []} - } -] +from .repository import upsert_pack +from .models import PackData, PackConcept, PackCompliance def main(): Base.metadata.create_all(bind=engine) with SessionLocal() as db: if db.execute(select(UserORM).where(UserORM.username == "wesley")).scalar_one_or_none() is None: db.add(UserORM(username="wesley", password_hash=hash_password("demo-pass"), role="admin", is_active=True)) - for pack in PACKS: - if db.get(PackORM, pack["id"]) is None: - db.add(PackORM(id=pack["id"], title=pack["title"], subtitle=pack["subtitle"], level=pack["level"], data_json=json.dumps(pack), is_published=True)) db.commit() - print("Seeded database. Demo user: wesley / demo-pass") - -if __name__ == "__main__": - main() + upsert_pack( + PackData( + id="wesley-private-pack", + title="Wesley Private Pack", + subtitle="Personal pack example.", + level="novice-friendly", + concepts=[PackConcept(id="intro", title="Intro", prerequisites=[], masteryDimension="mastery", exerciseReward="Intro marker")], + onboarding={"headline":"Start privately","body":"Personal pack lane.","checklist":["Create pack","Use pack"]}, + compliance=PackCompliance() + ), + submitted_by_user_id=1, + policy_lane="personal", + is_published=True, + change_summary="Initial personal pack" + ) diff --git a/src/didactopus/worker.py b/src/didactopus/worker.py index 3cea356..ad99705 100644 --- a/src/didactopus/worker.py +++ b/src/didactopus/worker.py @@ -8,27 +8,14 @@ def process_job(job_id: int): job = get_evaluator_job(job_id) if job is None: return - update_evaluator_job(job_id, "running") score = 0.78 if len(job.submitted_text.strip()) > 20 else 0.62 confidence_hint = 0.72 if len(job.submitted_text.strip()) > 20 else 0.45 notes = "Prototype evaluator: longer responses scored somewhat higher." - update_evaluator_job(job_id, "completed", score=score, confidence_hint=confidence_hint, notes=notes) + update_evaluator_job(job_id, "completed", score=score, confidence_hint=confidence_hint, notes=notes, trace={"notes": ["completed"]}) state = load_learner_state(job.learner_id) - state = apply_evidence(state, EvidenceEvent( - concept_id=job.concept_id, - dimension="mastery", - score=score, - confidence_hint=confidence_hint, - timestamp="2026-03-13T12:00:00+00:00", - kind="review", - source_id=f"evaluator-job-{job_id}", - )) + state = apply_evidence(state, EvidenceEvent(concept_id=job.concept_id, dimension="mastery", score=score, confidence_hint=confidence_hint, timestamp="2026-03-13T12:00:00+00:00", kind="review", source_id=f"evaluator-job-{job_id}")) save_learner_state(state) def main(): - print("Didactopus worker scaffold running. Replace this with a real queue worker.") while True: time.sleep(60) - -if __name__ == "__main__": - main() diff --git a/webui/index.html b/webui/index.html index 9cbd33a..2a0a12d 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3,7 +3,7 @@ - Didactopus UI Workflows + Didactopus Agent Audit and Key Rotation
diff --git a/webui/package.json b/webui/package.json index 28ef3ef..da8cee7 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,17 +1,9 @@ { - "name": "didactopus-ui-workflows", + "name": "didactopus-agent-audit-ui", "private": true, "version": "0.1.0", "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build" - }, - "dependencies": { - "react": "^18.3.1", - "react-dom": "^18.3.1" - }, - "devDependencies": { - "vite": "^5.4.0" - } + "scripts": { "dev": "vite", "build": "vite build" }, + "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" }, + "devDependencies": { "vite": "^5.4.0" } } diff --git a/webui/src/App.jsx b/webui/src/App.jsx index 8d8462b..3948204 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.jsx @@ -1,26 +1,18 @@ -import React, { useEffect, useMemo, useState } from "react"; -import { - login, refresh, fetchPacks, fetchAdminPacks, upsertPack, publishPack, - createLearner, listLearners, fetchLearnerState, fetchRecommendations, postEvidence, - submitEvaluatorJob, fetchEvaluatorHistory -} from "./api"; +import React, { useEffect, useState } from "react"; +import { login, refresh, fetchDeploymentPolicy, fetchAgentCapabilities, listServiceAccounts, createServiceAccount, rotateServiceAccount, setServiceAccountState, listAgentAuditLogs } from "./api"; import { loadAuth, saveAuth, clearAuth } from "./authStore"; function LoginView({ onAuth }) { const [username, setUsername] = useState("wesley"); const [password, setPassword] = useState("demo-pass"); const [error, setError] = useState(""); - async function doLogin() { try { const result = await login(username, password); saveAuth(result); onAuth(result); - } catch { - setError("Login failed"); - } + } catch { setError("Login failed"); } } - return (
@@ -34,294 +26,133 @@ function LoginView({ onAuth }) { ); } -function NavTabs({ tab, setTab, role }) { - return ( -
- - - - {role === "admin" ? : null} -
- ); -} - export default function App() { const [auth, setAuth] = useState(loadAuth()); - const [tab, setTab] = useState("learner"); - const [packs, setPacks] = useState([]); - const [adminPacks, setAdminPacks] = useState([]); - const [learners, setLearners] = useState([]); - const [selectedLearnerId, setSelectedLearnerId] = useState("wesley-learner"); - const [selectedPackId, setSelectedPackId] = useState(""); - const [learnerState, setLearnerState] = useState(null); - const [cards, setCards] = useState([]); - const [history, setHistory] = useState([]); - const [newLearnerId, setNewLearnerId] = useState("wesley-learner"); - const [newPackJson, setNewPackJson] = useState(JSON.stringify({ - pack: { - id: "new-pack", - title: "New Pack", - subtitle: "Editable admin pack scaffold", - level: "novice-friendly", - concepts: [], - onboarding: { headline: "Start here", body: "Begin", checklist: [] }, - compliance: { sources: 0, attributionRequired: false, shareAlikeRequired: false, noncommercialOnly: false, flags: [] } - }, - is_published: false - }, null, 2)); - const [message, setMessage] = useState(""); + const [policy, setPolicy] = useState(null); + const [caps, setCaps] = useState(null); + const [serviceAccounts, setServiceAccounts] = useState([]); + const [auditLogs, setAuditLogs] = useState([]); + const [created, setCreated] = useState(null); + const [rotated, setRotated] = useState(null); + const [form, setForm] = useState({ name: "agent-learner-1", description: "AI learner account", scopes: ["packs:read","learners:read","learners:write","recommendations:read","evaluators:submit","evaluators:read"] }); async function refreshAuthToken() { - if (!auth?.refresh_token) return null + if (!auth?.refresh_token) return null; try { - const result = await refresh(auth.refresh_token) - saveAuth(result) - setAuth(result) - return result + const result = await refresh(auth.refresh_token); + saveAuth(result); + setAuth(result); + return result; } catch { - clearAuth() - setAuth(null) - return null + clearAuth(); + setAuth(null); + return null; } } async function guarded(fn) { - try { - return await fn(auth.access_token) - } catch { - const next = await refreshAuthToken() - if (!next) throw new Error("auth failed") - return await fn(next.access_token) + try { return await fn(auth.access_token); } + catch { + const next = await refreshAuthToken(); + if (!next) throw new Error("auth failed"); + return await fn(next.access_token); + } + } + + async function reload() { + setPolicy(await guarded((token) => fetchDeploymentPolicy(token))); + setCaps(await guarded((token) => fetchAgentCapabilities(token))); + if (auth.role === "admin") { + setServiceAccounts(await guarded((token) => listServiceAccounts(token))); + setAuditLogs(await guarded((token) => listAgentAuditLogs(token))); } } useEffect(() => { - if (!auth) return - async function load() { - const p = await guarded((token) => fetchPacks(token)) - setPacks(p) - setSelectedPackId((prev) => prev || p[0]?.id || "") - const ls = await guarded((token) => listLearners(token)) - setLearners(ls) - if (ls.length === 0) { - await guarded((token) => createLearner(token, selectedLearnerId, selectedLearnerId)) - const ls2 = await guarded((token) => listLearners(token)) - setLearners(ls2) - } - if (auth.role === "admin") { - const ap = await guarded((token) => fetchAdminPacks(token)) - setAdminPacks(ap) - } - } - load() - }, [auth]) + if (!auth) return; + reload(); + }, [auth]); - useEffect(() => { - if (!auth || !selectedLearnerId || !selectedPackId) return - async function loadLearnerStuff() { - const state = await guarded((token) => fetchLearnerState(token, selectedLearnerId)) - setLearnerState(state) - const recs = await guarded((token) => fetchRecommendations(token, selectedLearnerId, selectedPackId)) - setCards(recs.cards || []) - const hist = await guarded((token) => fetchEvaluatorHistory(token, selectedLearnerId)) - setHistory(hist) - } - loadLearnerStuff() - }, [auth, selectedLearnerId, selectedPackId]) - - const pack = useMemo(() => packs.find((p) => p.id === selectedPackId) || null, [packs, selectedPackId]) - - async function simulateCard(card) { - await guarded((token) => postEvidence(token, selectedLearnerId, { - concept_id: card.conceptId, - dimension: "mastery", - score: card.scoreHint, - confidence_hint: card.confidenceHint, - timestamp: new Date().toISOString(), - kind: "checkpoint", - source_id: `ui-${card.id}` - })) - const state = await guarded((token) => fetchLearnerState(token, selectedLearnerId)) - setLearnerState(state) - const recs = await guarded((token) => fetchRecommendations(token, selectedLearnerId, selectedPackId)) - setCards(recs.cards || []) - setMessage(card.reward) + async function createNow() { + const result = await guarded((token) => createServiceAccount(token, form)); + setCreated(result); + await reload(); } - async function runEvaluator() { - if (!pack?.concepts?.length) return - await guarded((token) => submitEvaluatorJob(token, selectedLearnerId, { - pack_id: selectedPackId, - concept_id: pack.concepts[0].id, - submitted_text: "This is a moderately detailed learner response intended to trigger a somewhat better prototype evaluator score.", - kind: "checkpoint" - })) - setTimeout(async () => { - const hist = await guarded((token) => fetchEvaluatorHistory(token, selectedLearnerId)) - setHistory(hist) - const state = await guarded((token) => fetchLearnerState(token, selectedLearnerId)) - setLearnerState(state) - const recs = await guarded((token) => fetchRecommendations(token, selectedLearnerId, selectedPackId)) - setCards(recs.cards || []) - }, 1200) + async function rotateNow(name) { + const result = await guarded((token) => rotateServiceAccount(token, name)); + setRotated(result); + await reload(); } - async function createLearnerNow() { - await guarded((token) => createLearner(token, newLearnerId, newLearnerId)) - const ls = await guarded((token) => listLearners(token)) - setLearners(ls) - setSelectedLearnerId(newLearnerId) + async function toggleState(name, isActive) { + await guarded((token) => setServiceAccountState(token, name, isActive)); + await reload(); } - async function savePack() { - const payload = JSON.parse(newPackJson) - await guarded((token) => upsertPack(token, payload)) - const ap = await guarded((token) => fetchAdminPacks(token)) - const p = await guarded((token) => fetchPacks(token)) - setAdminPacks(ap) - setPacks(p) - } - - async function togglePublish(packId, isPublished) { - await guarded((token) => publishPack(token, packId, isPublished)) - const ap = await guarded((token) => fetchAdminPacks(token)) - const p = await guarded((token) => fetchPacks(token)) - setAdminPacks(ap) - setPacks(p) - } - - if (!auth) return + if (!auth) return ; return (
-

Didactopus workflow scaffold

-

Login, refresh, learner management, evaluator history, and admin pack publication workflows.

+

Didactopus agent audit + key rotation

+

Scoped machine identities with audit events, rotation, and disable controls.

Signed in as {auth.username} ({auth.role})
- {message ?
{message}
: null} -
-
- - -
+
- +
+
+

Deployment policy

+
{JSON.stringify(policy, null, 2)}
+

Agent capabilities

+
{JSON.stringify(caps, null, 2)}
+
- {tab === "learner" && ( -
-
-

Learner dashboard

-

Pack: {pack?.title || "-"}

-

{pack?.subtitle || ""}

- -

Next actions

-
- {cards.length ? cards.map((card) => ( -
-
-
-

{card.title}

-
{card.minutes} minutes
-
-
{card.reward}
-
-

{card.reason}

-
- Why this is recommended -
    {card.why.map((w, idx) =>
  • {w}
  • )}
-
- -
- )) :
No recommendations available.
} -
-

Learner state snapshot

-
{JSON.stringify(learnerState, null, 2)}
-
-
- )} +
+

Service accounts

+ {auth.role === "admin" ? ( + <> + + + + +

Created credential

+
{JSON.stringify(created, null, 2)}
+

Rotated credential

+
{JSON.stringify(rotated, null, 2)}
+

Existing accounts

+
+ + + + {serviceAccounts.map((sa) => ( + + + + + + + ))} + +
NameActiveScopesActions
{sa.name}{String(sa.is_active)}{sa.scopes.join(", ")} + + +
+
+ + ) : ( +
Admin required.
+ )} +
- {tab === "history" && ( -
-
-

Evaluator history

- {history.length ? ( - - - - {history.map((row) => ( - - - - - - - - - ))} - -
JobStatusConceptScoreConfidenceNotes
{row.job_id}{row.status}{row.concept_id}{row.result_score ?? "-"}{row.result_confidence_hint ?? "-"}{row.result_notes || ""}
- ) :
No evaluator jobs yet.
} -
-
- )} - - {tab === "manage" && ( -
-
-

Learner management

- - -
-
-

Existing learners

- - - - {learners.map((row) => ( - - - - - - ))} - -
Learner IDDisplay nameOwner
{row.learner_id}{row.display_name}{row.owner_user_id}
-
-
- )} - - {tab === "admin" && auth.role === "admin" && ( -
-
-

Pack editor

-