Apply ZIP update: 150-didactopus-agent-service-account-layer.zip [2026-03-14T13:19:26]
This commit is contained in:
parent
5c3ba24e90
commit
15d52f66bc
|
|
@ -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"
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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 = ""
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
with SessionLocal() as db:
|
||||||
|
sa = db.execute(select(ServiceAccountORM).where(ServiceAccountORM.name == name)).scalar_one_or_none()
|
||||||
if sa is None or not sa.is_active or not verify_password(secret, sa.secret_hash):
|
if sa is None or not sa.is_active or not verify_password(secret, sa.secret_hash):
|
||||||
return None
|
return None
|
||||||
return sa
|
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):
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
useEffect(() => {
|
||||||
|
if (!auth) return;
|
||||||
|
async function load() {
|
||||||
setPolicy(await guarded((token) => fetchDeploymentPolicy(token)));
|
setPolicy(await guarded((token) => fetchDeploymentPolicy(token)));
|
||||||
setCaps(await guarded((token) => fetchAgentCapabilities(token)));
|
setCaps(await guarded((token) => fetchAgentCapabilities(token)));
|
||||||
if (auth.role === "admin") {
|
if (auth.role === "admin") {
|
||||||
setServiceAccounts(await guarded((token) => listServiceAccounts(token)));
|
setServiceAccounts(await guarded((token) => listServiceAccounts(token)));
|
||||||
setAuditLogs(await guarded((token) => listAgentAuditLogs(token)));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
load();
|
||||||
useEffect(() => {
|
|
||||||
if (!auth) return;
|
|
||||||
reload();
|
|
||||||
}, [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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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(); }
|
|
||||||
|
|
|
||||||
|
|
@ -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; } }
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue