Apply ZIP update: 150-didactopus-agent-service-account-layer.zip [2026-03-14T13:19:26]

This commit is contained in:
welsberr 2026-03-14 13:29:55 -04:00
parent 5c3ba24e90
commit 15d52f66bc
10 changed files with 66 additions and 204 deletions

View File

@ -8,9 +8,11 @@ version = "0.1.0"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"pydantic>=2.7", "pydantic>=2.7",
"pyyaml>=6.0",
"fastapi>=0.115", "fastapi>=0.115",
"uvicorn>=0.30", "uvicorn>=0.30",
"sqlalchemy>=2.0", "sqlalchemy>=2.0",
"psycopg[binary]>=3.1",
"passlib[bcrypt]>=1.7", "passlib[bcrypt]>=1.7",
"python-jose[cryptography]>=3.3" "python-jose[cryptography]>=3.3"
] ]

View File

@ -5,19 +5,8 @@ from fastapi.middleware.cors import CORSMiddleware
import uvicorn import uvicorn
from .config import load_settings from .config import load_settings
from .db import Base, engine from .db import Base, engine
from .models import ( from .models import LoginRequest, ServiceAccountLoginRequest, ServiceAccountCreateRequest, ServiceToken, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest, ContributionSubmissionCreate, AgentCapabilityManifest, AgentLearnerPlanRequest, AgentLearnerPlanResponse
LoginRequest, ServiceAccountLoginRequest, ServiceAccountCreateRequest, ServiceAccountRotateRequest, from .repository import authenticate_user, get_user_by_id, create_service_account, list_service_accounts, authenticate_service_account, 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
ServiceAccountStateRequest, ServiceToken, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState,
EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest, AgentCapabilityManifest,
AgentLearnerPlanRequest, AgentLearnerPlanResponse
)
from .repository import (
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 .engine import apply_evidence, recommend_next
from .auth import issue_access_token, issue_refresh_token, issue_service_access_token, decode_token, new_token_id, new_secret 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 from .worker import process_job
@ -25,6 +14,19 @@ from .worker import process_job
settings = load_settings() settings = load_settings()
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
SERVICE_SCOPE_MAP = {
"packs:read": "packs:read",
"packs:write_personal": "packs:write_personal",
"contributions:submit": "contributions:submit",
"learners:read": "learners:read",
"learners:write": "learners:write",
"recommendations:read": "recommendations:read",
"evaluators:submit": "evaluators:submit",
"evaluators:read": "evaluators:read",
"governance:read": "governance:read",
"governance:write": "governance:write",
}
app = FastAPI(title="Didactopus API Prototype") 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=["*"])
@ -39,37 +41,25 @@ def current_actor(authorization: str = Header(default="")):
raise HTTPException(status_code=401, detail="Unauthorized") raise HTTPException(status_code=401, detail="Unauthorized")
return {"actor_type": "user", "user": user, "scopes": None} return {"actor_type": "user", "user": user, "scopes": None}
if payload.get("kind") == "service": if payload.get("kind") == "service":
return { return {"actor_type": "service", "service_account_id": int(payload["sub"]), "service_account_name": payload.get("service_account_name"), "scopes": payload.get("scopes", [])}
"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") raise HTTPException(status_code=401, detail="Unauthorized")
def require_user(actor = Depends(current_actor)):
if actor["actor_type"] != "user":
raise HTTPException(status_code=403, detail="Human user required")
return actor["user"]
def require_admin(actor = Depends(current_actor)): def require_admin(actor = Depends(current_actor)):
if actor["actor_type"] != "user" or actor["user"].role != "admin": if actor["actor_type"] != "user" or actor["user"].role != "admin":
raise HTTPException(status_code=403, detail="Admin role required") raise HTTPException(status_code=403, detail="Admin role required")
return actor["user"] return actor["user"]
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 require_scope(scope: str):
def inner(actor = Depends(current_actor)): def inner(actor = Depends(current_actor)):
if actor["actor_type"] == "user": if actor["actor_type"] == "user":
return actor return actor
scopes = set(actor.get("scopes") or []) scopes = set(actor.get("scopes") or [])
if scope not in scopes: 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}") raise HTTPException(status_code=403, detail=f"Missing scope: {scope}")
return actor return actor
return inner return inner
@ -148,7 +138,6 @@ def api_agent_learner_plan(payload: AgentLearnerPlanRequest, actor = Depends(req
if pack is None: if pack is None:
raise HTTPException(status_code=404, detail="Pack not found") raise HTTPException(status_code=404, detail="Pack not found")
cards = recommend_next(state, pack) 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"]) 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") @app.post("/api/admin/service-accounts")
@ -161,31 +150,10 @@ def api_create_service_account(payload: ServiceAccountCreateRequest, user = Depe
def api_list_service_accounts(user = Depends(require_admin)): def api_list_service_accounts(user = Depends(require_admin)):
return list_service_accounts() 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") @app.get("/api/packs")
def api_list_packs(actor = Depends(require_scope("packs:read"))): def api_list_packs(actor = Depends(require_scope("packs:read"))):
user_id = actor["user"].id if actor["actor_type"] == "user" else None 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"))] return [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.post("/api/packs") @app.post("/api/packs")
def api_upsert_personal_pack(payload: CreatePackRequest, actor = Depends(require_scope("packs:write_personal"))): def api_upsert_personal_pack(payload: CreatePackRequest, actor = Depends(require_scope("packs:write_personal"))):
@ -196,6 +164,11 @@ def api_upsert_personal_pack(payload: CreatePackRequest, actor = Depends(require
upsert_pack(payload.pack, submitted_by_user_id=actor["user"].id, policy_lane="personal", is_published=payload.is_published, change_summary=payload.change_summary) 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"} return {"ok": True, "pack_id": payload.pack.id, "policy_lane": "personal"}
@app.post("/api/contributions")
def api_create_contribution(payload: ContributionSubmissionCreate, actor = Depends(require_scope("contributions:submit"))):
contributor_id = actor["user"].id if actor["actor_type"] == "user" else 0
return {"ok": True, "note": "Contribution flow placeholder for this scaffold", "contributor_id": contributor_id}
@app.post("/api/learners") @app.post("/api/learners")
def api_create_learner(payload: CreateLearnerRequest, actor = Depends(require_scope("learners:write"))): def api_create_learner(payload: CreateLearnerRequest, actor = Depends(require_scope("learners:write"))):
if actor["actor_type"] != "user": if actor["actor_type"] != "user":
@ -206,27 +179,22 @@ def api_create_learner(payload: CreateLearnerRequest, actor = Depends(require_sc
@app.get("/api/learners/{learner_id}/state") @app.get("/api/learners/{learner_id}/state")
def api_get_learner_state(learner_id: str, actor = Depends(require_scope("learners:read"))): def api_get_learner_state(learner_id: str, actor = Depends(require_scope("learners:read"))):
ensure_learner_access(actor, learner_id) ensure_learner_access(actor, learner_id)
state = load_learner_state(learner_id).model_dump() return 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") @app.put("/api/learners/{learner_id}/state")
def api_put_learner_state(learner_id: str, state: LearnerState, actor = Depends(require_scope("learners:write"))): def api_put_learner_state(learner_id: str, state: LearnerState, actor = Depends(require_scope("learners:write"))):
ensure_learner_access(actor, learner_id) ensure_learner_access(actor, learner_id)
if learner_id != state.learner_id: if learner_id != state.learner_id:
raise HTTPException(status_code=400, detail="Learner ID mismatch") raise HTTPException(status_code=400, detail="Learner ID mismatch")
result = save_learner_state(state).model_dump() return 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") @app.post("/api/learners/{learner_id}/evidence")
def api_post_evidence(learner_id: str, event: EvidenceEvent, actor = Depends(require_scope("learners:write"))): def api_post_evidence(learner_id: str, event: EvidenceEvent, actor = Depends(require_scope("learners:write"))):
ensure_learner_access(actor, learner_id) ensure_learner_access(actor, learner_id)
state = load_learner_state(learner_id) state = load_learner_state(learner_id)
state = apply_evidence(state, event) state = apply_evidence(state, event)
result = save_learner_state(state).model_dump() save_learner_state(state)
audit_service_action(actor, "learner_evidence_post", learner_id, "ok", {"concept_id": event.concept_id}) return state.model_dump()
return result
@app.get("/api/learners/{learner_id}/recommendations/{pack_id}") @app.get("/api/learners/{learner_id}/recommendations/{pack_id}")
def api_get_recommendations(learner_id: str, pack_id: str, actor = Depends(require_scope("recommendations:read"))): def api_get_recommendations(learner_id: str, pack_id: str, actor = Depends(require_scope("recommendations:read"))):
@ -236,9 +204,7 @@ def api_get_recommendations(learner_id: str, pack_id: str, actor = Depends(requi
pack = get_pack(pack_id) pack = get_pack(pack_id)
if pack is None: if pack is None:
raise HTTPException(status_code=404, detail="Pack not found") raise HTTPException(status_code=404, detail="Pack not found")
cards = recommend_next(state, pack) return {"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) @app.post("/api/learners/{learner_id}/evaluator-jobs", response_model=EvaluatorJobStatus)
def api_submit_evaluator_job(learner_id: str, payload: EvaluatorSubmission, background_tasks: BackgroundTasks, actor = Depends(require_scope("evaluators:submit"))): def api_submit_evaluator_job(learner_id: str, payload: EvaluatorSubmission, background_tasks: BackgroundTasks, actor = Depends(require_scope("evaluators:submit"))):
@ -246,7 +212,6 @@ def api_submit_evaluator_job(learner_id: str, payload: EvaluatorSubmission, back
ensure_pack_access(actor, payload.pack_id) ensure_pack_access(actor, payload.pack_id)
job_id = create_evaluator_job(learner_id, payload.pack_id, payload.concept_id, payload.submitted_text) job_id = create_evaluator_job(learner_id, payload.pack_id, payload.concept_id, payload.submitted_text)
background_tasks.add_task(process_job, job_id) 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") return EvaluatorJobStatus(job_id=job_id, status="queued")
@app.get("/api/evaluator-jobs/{job_id}", response_model=EvaluatorJobStatus) @app.get("/api/evaluator-jobs/{job_id}", response_model=EvaluatorJobStatus)
@ -254,14 +219,12 @@ def api_get_evaluator_job(job_id: int, actor = Depends(require_scope("evaluators
job = get_evaluator_job(job_id) job = get_evaluator_job(job_id)
if job is None: if job is None:
raise HTTPException(status_code=404, detail="Job not found") 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) 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") @app.get("/api/learners/{learner_id}/evaluator-history")
def api_get_evaluator_history(learner_id: str, actor = Depends(require_scope("evaluators:read"))): def api_get_evaluator_history(learner_id: str, actor = Depends(require_scope("evaluators:read"))):
ensure_learner_access(actor, learner_id) ensure_learner_access(actor, learner_id)
jobs = list_evaluator_jobs_for_learner(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] 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(): def main():

View File

@ -31,12 +31,6 @@ class ServiceAccountCreateRequest(BaseModel):
description: str = "" description: str = ""
scopes: list[str] = Field(default_factory=list) scopes: list[str] = Field(default_factory=list)
class ServiceAccountRotateRequest(BaseModel):
name: str
class ServiceAccountStateRequest(BaseModel):
is_active: bool
class RefreshRequest(BaseModel): class RefreshRequest(BaseModel):
refresh_token: str refresh_token: str
@ -60,8 +54,6 @@ class AgentCapabilityManifest(BaseModel):
supports_governance_endpoints: bool = True supports_governance_endpoints: bool = True
supports_review_queue: bool = True supports_review_queue: bool = True
supports_service_accounts: bool = True supports_service_accounts: bool = True
supports_agent_audit_logs: bool = True
supports_service_account_rotation: bool = True
class PackConcept(BaseModel): class PackConcept(BaseModel):
id: str id: str
@ -92,6 +84,10 @@ class CreatePackRequest(BaseModel):
is_published: bool = False is_published: bool = False
change_summary: str = "" change_summary: str = ""
class ContributionSubmissionCreate(BaseModel):
pack: PackData
submission_summary: str = ""
class CreateLearnerRequest(BaseModel): class CreateLearnerRequest(BaseModel):
learner_id: str learner_id: str
display_name: str = "" display_name: str = ""

View File

@ -20,17 +20,6 @@ class ServiceAccountORM(Base):
secret_hash: Mapped[str] = mapped_column(String(255)) secret_hash: Mapped[str] = mapped_column(String(255))
is_active: Mapped[bool] = mapped_column(Boolean, default=True) 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): class RefreshTokenORM(Base):
__tablename__ = "refresh_tokens" __tablename__ = "refresh_tokens"
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)

View File

@ -3,7 +3,7 @@ import json
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import select from sqlalchemy import select
from .db import SessionLocal from .db import SessionLocal
from .orm import UserORM, ServiceAccountORM, AgentAuditLogORM, RefreshTokenORM, PackORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, EvaluatorJobORM from .orm import UserORM, ServiceAccountORM, RefreshTokenORM, PackORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, EvaluatorJobORM
from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent, DeploymentPolicyProfile from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent, DeploymentPolicyProfile
from .auth import verify_password, hash_password from .auth import verify_password, hash_password
from .config import load_settings from .config import load_settings
@ -58,63 +58,13 @@ def list_service_accounts():
rows = db.execute(select(ServiceAccountORM).order_by(ServiceAccountORM.id)).scalars().all() 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] 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): 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: with SessionLocal() as db:
sa = db.execute(select(ServiceAccountORM).where(ServiceAccountORM.name == name)).scalar_one_or_none() sa = db.execute(select(ServiceAccountORM).where(ServiceAccountORM.name == name)).scalar_one_or_none()
if sa is None: if sa is None or not sa.is_active or not verify_password(secret, sa.secret_hash):
return None return None
sa.secret_hash = hash_password(new_secret)
db.commit()
db.refresh(sa)
return 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): def store_refresh_token(user_id: int, token_id: str):
with SessionLocal() as db: with SessionLocal() as db:
db.add(RefreshTokenORM(user_id=user_id, token_id=token_id, is_revoked=False)) db.add(RefreshTokenORM(user_id=user_id, token_id=token_id, is_revoked=False))
@ -197,6 +147,14 @@ def create_learner(owner_user_id: int, learner_id: str, display_name: str = ""):
db.add(LearnerORM(id=learner_id, owner_user_id=owner_user_id, display_name=display_name)) db.add(LearnerORM(id=learner_id, owner_user_id=owner_user_id, display_name=display_name))
db.commit() 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: def learner_owned_by_user(user_id: int, learner_id: str) -> bool:
with SessionLocal() as db: with SessionLocal() as db:
learner = db.get(LearnerORM, learner_id) learner = db.get(LearnerORM, learner_id)

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Didactopus Agent Audit and Key Rotation</title> <title>Didactopus Agent Service Accounts</title>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</head> </head>
<body><div id="root"></div></body> <body><div id="root"></div></body>

View File

@ -1,5 +1,5 @@
{ {
"name": "didactopus-agent-audit-ui", "name": "didactopus-agent-service-account-ui",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { login, refresh, fetchDeploymentPolicy, fetchAgentCapabilities, listServiceAccounts, createServiceAccount, rotateServiceAccount, setServiceAccountState, listAgentAuditLogs } from "./api"; import { login, refresh, fetchDeploymentPolicy, fetchAgentCapabilities, listServiceAccounts, createServiceAccount } from "./api";
import { loadAuth, saveAuth, clearAuth } from "./authStore"; import { loadAuth, saveAuth, clearAuth } from "./authStore";
function LoginView({ onAuth }) { function LoginView({ onAuth }) {
@ -31,9 +31,7 @@ export default function App() {
const [policy, setPolicy] = useState(null); const [policy, setPolicy] = useState(null);
const [caps, setCaps] = useState(null); const [caps, setCaps] = useState(null);
const [serviceAccounts, setServiceAccounts] = useState([]); const [serviceAccounts, setServiceAccounts] = useState([]);
const [auditLogs, setAuditLogs] = useState([]);
const [created, setCreated] = useState(null); 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"] }); 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() { async function refreshAuthToken() {
@ -59,35 +57,22 @@ export default function App() {
} }
} }
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(() => { useEffect(() => {
if (!auth) return; if (!auth) return;
reload(); async function load() {
setPolicy(await guarded((token) => fetchDeploymentPolicy(token)));
setCaps(await guarded((token) => fetchAgentCapabilities(token)));
if (auth.role === "admin") {
setServiceAccounts(await guarded((token) => listServiceAccounts(token)));
}
}
load();
}, [auth]); }, [auth]);
async function createNow() { async function createNow() {
const result = await guarded((token) => createServiceAccount(token, form)); const result = await guarded((token) => createServiceAccount(token, form));
setCreated(result); setCreated(result);
await reload(); setServiceAccounts(await guarded((token) => listServiceAccounts(token)));
}
async function rotateNow(name) {
const result = await guarded((token) => rotateServiceAccount(token, name));
setRotated(result);
await reload();
}
async function toggleState(name, isActive) {
await guarded((token) => setServiceAccountState(token, name, isActive));
await reload();
} }
if (!auth) return <LoginView onAuth={setAuth} />; if (!auth) return <LoginView onAuth={setAuth} />;
@ -96,8 +81,8 @@ export default function App() {
<div className="page"> <div className="page">
<header className="hero"> <header className="hero">
<div> <div>
<h1>Didactopus agent audit + key rotation</h1> <h1>Didactopus agent service-account layer</h1>
<p>Scoped machine identities with audit events, rotation, and disable controls.</p> <p>First-class machine identities with scoped API access for AI learners.</p>
<div className="muted">Signed in as {auth.username} ({auth.role})</div> <div className="muted">Signed in as {auth.username} ({auth.role})</div>
</div> </div>
<button onClick={() => { clearAuth(); setAuth(null); }}>Logout</button> <button onClick={() => { clearAuth(); setAuth(null); }}>Logout</button>
@ -121,37 +106,13 @@ export default function App() {
<button className="primary" onClick={createNow}>Create service account</button> <button className="primary" onClick={createNow}>Create service account</button>
<h3>Created credential</h3> <h3>Created credential</h3>
<pre className="prebox">{JSON.stringify(created, null, 2)}</pre> <pre className="prebox">{JSON.stringify(created, null, 2)}</pre>
<h3>Rotated credential</h3>
<pre className="prebox">{JSON.stringify(rotated, null, 2)}</pre>
<h3>Existing accounts</h3> <h3>Existing accounts</h3>
<div className="table-wrap"> <pre className="prebox">{JSON.stringify(serviceAccounts, null, 2)}</pre>
<table className="table">
<thead><tr><th>Name</th><th>Active</th><th>Scopes</th><th>Actions</th></tr></thead>
<tbody>
{serviceAccounts.map((sa) => (
<tr key={sa.id}>
<td>{sa.name}</td>
<td>{String(sa.is_active)}</td>
<td>{sa.scopes.join(", ")}</td>
<td>
<button onClick={() => rotateNow(sa.name)}>Rotate</button>
<button onClick={() => toggleState(sa.name, !sa.is_active)}>{sa.is_active ? "Disable" : "Enable"}</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</> </>
) : ( ) : (
<div className="muted">Admin required.</div> <div className="muted">Admin required.</div>
)} )}
</section> </section>
<section className="card full">
<h2>Agent audit logs</h2>
<pre className="prebox tall">{JSON.stringify(auditLogs, null, 2)}</pre>
</section>
</main> </main>
</div> </div>
); );

View File

@ -20,6 +20,3 @@ export async function fetchDeploymentPolicy(token) { const res = await fetch(`${
export async function fetchAgentCapabilities(token) { const res = await fetch(`${API}/agent/capabilities`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchAgentCapabilities failed"); return await res.json(); } export async function fetchAgentCapabilities(token) { const res = await fetch(`${API}/agent/capabilities`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchAgentCapabilities failed"); return await res.json(); }
export async function listServiceAccounts(token) { const res = await fetch(`${API}/admin/service-accounts`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("listServiceAccounts failed"); return await res.json(); } export async function listServiceAccounts(token) { const res = await fetch(`${API}/admin/service-accounts`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("listServiceAccounts failed"); return await res.json(); }
export async function createServiceAccount(token, payload) { const res = await fetch(`${API}/admin/service-accounts`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("createServiceAccount failed"); return await res.json(); } export async function createServiceAccount(token, payload) { const res = await fetch(`${API}/admin/service-accounts`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("createServiceAccount failed"); return await res.json(); }
export async function rotateServiceAccount(token, name) { const res = await fetch(`${API}/admin/service-accounts/rotate`, { method: "POST", headers: authHeaders(token), body: JSON.stringify({ name }) }); if (!res.ok) throw new Error("rotateServiceAccount failed"); return await res.json(); }
export async function setServiceAccountState(token, name, is_active) { const res = await fetch(`${API}/admin/service-accounts/state?name=${encodeURIComponent(name)}`, { method: "POST", headers: authHeaders(token), body: JSON.stringify({ is_active }) }); if (!res.ok) throw new Error("setServiceAccountState failed"); return await res.json(); }
export async function listAgentAuditLogs(token) { const res = await fetch(`${API}/admin/agent-audit-logs`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("listAgentAuditLogs failed"); return await res.json(); }

View File

@ -8,17 +8,13 @@ body { margin:0; font-family:Arial, Helvetica, sans-serif; background:var(--bg);
.hero { background:var(--card); border:1px solid var(--border); border-radius:22px; padding:24px; display:flex; justify-content:space-between; gap:16px; } .hero { background:var(--card); border:1px solid var(--border); border-radius:22px; padding:24px; display:flex; justify-content:space-between; gap:16px; }
label { display:block; font-weight:600; margin-bottom:10px; } label { display:block; font-weight:600; margin-bottom:10px; }
input { width:100%; margin-top:6px; border:1px solid var(--border); border-radius:10px; padding:10px; font:inherit; background:white; } input { width:100%; margin-top:6px; border:1px solid var(--border); border-radius:10px; padding:10px; font:inherit; background:white; }
button { border:1px solid var(--border); background:white; border-radius:12px; padding:8px 12px; cursor:pointer; margin-right:8px; } button { border:1px solid var(--border); background:white; border-radius:12px; padding:10px 14px; cursor:pointer; }
.primary { background:var(--accent); color:white; border-color:var(--accent); } .primary { background:var(--accent); color:white; border-color:var(--accent); }
.card { background:var(--card); border:1px solid var(--border); border-radius:18px; padding:18px; } .card { background:var(--card); border:1px solid var(--border); border-radius:18px; padding:18px; }
.narrow { margin-top:60px; } .narrow { margin-top:60px; }
.layout { display:grid; gap:16px; } .layout { display:grid; gap:16px; }
.twocol { grid-template-columns:1fr 1fr; } .twocol { grid-template-columns:1fr 1fr; }
.full { grid-column:1 / -1; } .prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:340px; }
.prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:280px; }
.prebox.tall { max-height:420px; }
.muted { color:var(--muted); } .muted { color:var(--muted); }
.error { color:#b42318; margin-top:10px; } .error { color:#b42318; margin-top:10px; }
.table { width:100%; border-collapse:collapse; } @media (max-width:1100px) { .twocol { grid-template-columns:1fr; } .hero { flex-direction:column; } }
.table th, .table td { border-bottom:1px solid var(--border); text-align:left; padding:8px; vertical-align:top; }
@media (max-width:1100px) { .twocol { grid-template-columns:1fr; } .hero { flex-direction:column; } .full { grid-column:auto; } }