diff --git a/pyproject.toml b/pyproject.toml index 53c7bda..17c47da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,9 +8,11 @@ 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 ea39309..ceca6ff 100644 --- a/src/didactopus/api.py +++ b/src/didactopus/api.py @@ -5,19 +5,8 @@ from fastapi.middleware.cors import CORSMiddleware import uvicorn from .config import load_settings from .db import Base, engine -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, 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 .models import LoginRequest, ServiceAccountLoginRequest, ServiceAccountCreateRequest, ServiceToken, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest, ContributionSubmissionCreate, AgentCapabilityManifest, AgentLearnerPlanRequest, AgentLearnerPlanResponse +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 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 .worker import process_job @@ -25,6 +14,19 @@ from .worker import process_job settings = load_settings() 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.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") 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", []), - } + 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_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)): if actor["actor_type"] != "user" or actor["user"].role != "admin": raise HTTPException(status_code=403, detail="Admin role required") 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 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 @@ -148,7 +138,6 @@ def api_agent_learner_plan(payload: AgentLearnerPlanRequest, actor = Depends(req 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") @@ -161,31 +150,10 @@ def api_create_service_account(payload: ServiceAccountCreateRequest, user = Depe 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(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 + return [p.model_dump() for p in list_packs_for_user(user_id, include_unpublished=(actor["actor_type"] == "user" and actor["user"].role == "admin"))] @app.post("/api/packs") 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) 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") def api_create_learner(payload: CreateLearnerRequest, actor = Depends(require_scope("learners:write"))): 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") 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 + return load_learner_state(learner_id).model_dump() @app.put("/api/learners/{learner_id}/state") 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") - result = save_learner_state(state).model_dump() - audit_service_action(actor, "learner_state_write", learner_id, "ok", {"records": len(result.get("records", []))}) - return result + return save_learner_state(state).model_dump() @app.post("/api/learners/{learner_id}/evidence") 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) - result = save_learner_state(state).model_dump() - audit_service_action(actor, "learner_evidence_post", learner_id, "ok", {"concept_id": event.concept_id}) - return result + save_learner_state(state) + return state.model_dump() @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"))): @@ -236,9 +204,7 @@ def api_get_recommendations(learner_id: str, pack_id: str, actor = Depends(requi pack = get_pack(pack_id) if pack is None: raise HTTPException(status_code=404, detail="Pack not found") - cards = recommend_next(state, pack) - audit_service_action(actor, "recommendations_read", f"{learner_id}:{pack_id}", "ok", {"cards": len(cards)}) - return {"cards": cards} + return {"cards": recommend_next(state, pack)} @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"))): @@ -246,7 +212,6 @@ def api_submit_evaluator_job(learner_id: str, payload: EvaluatorSubmission, back 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) @@ -254,14 +219,12 @@ def api_get_evaluator_job(job_id: int, actor = Depends(require_scope("evaluators 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, 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(): diff --git a/src/didactopus/models.py b/src/didactopus/models.py index 4462baa..87b1f84 100644 --- a/src/didactopus/models.py +++ b/src/didactopus/models.py @@ -31,12 +31,6 @@ class ServiceAccountCreateRequest(BaseModel): 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 @@ -60,8 +54,6 @@ class AgentCapabilityManifest(BaseModel): 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 @@ -92,6 +84,10 @@ class CreatePackRequest(BaseModel): is_published: bool = False change_summary: str = "" +class ContributionSubmissionCreate(BaseModel): + pack: PackData + submission_summary: str = "" + class CreateLearnerRequest(BaseModel): learner_id: str display_name: str = "" diff --git a/src/didactopus/orm.py b/src/didactopus/orm.py index 4abc0ed..b95271e 100644 --- a/src/didactopus/orm.py +++ b/src/didactopus/orm.py @@ -20,17 +20,6 @@ class ServiceAccountORM(Base): 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) diff --git a/src/didactopus/repository.py b/src/didactopus/repository.py index fb8fc6a..18d011c 100644 --- a/src/didactopus/repository.py +++ b/src/didactopus/repository.py @@ -3,7 +3,7 @@ import json from datetime import datetime, timezone from sqlalchemy import select 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 .auth import verify_password, hash_password from .config import load_settings @@ -58,63 +58,13 @@ def list_service_accounts(): 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: + if sa is None or not sa.is_active or not verify_password(secret, sa.secret_hash): 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)) @@ -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.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) diff --git a/webui/index.html b/webui/index.html index 2a0a12d..1356138 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3,7 +3,7 @@
-Scoped machine identities with audit events, rotation, and disable controls.
+First-class machine identities with scoped API access for AI learners.
{JSON.stringify(created, null, 2)}
- {JSON.stringify(rotated, null, 2)}
| Name | Active | Scopes | Actions |
|---|---|---|---|
| {sa.name} | -{String(sa.is_active)} | -{sa.scopes.join(", ")} | -- - - | -
{JSON.stringify(serviceAccounts, null, 2)}
>
) : (
{JSON.stringify(auditLogs, null, 2)}
-