Improved API docs, removed DELETE endpoint from user

This commit is contained in:
2026-01-26 13:42:08 +00:00
parent c115f41dac
commit 548681face
11 changed files with 171 additions and 58 deletions

1
agent/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
keywarden-agent

View File

@@ -18,15 +18,15 @@ from .routers.agent import build_router as build_agent_router
from django.contrib.admin.views.decorators import staff_member_required
def register_routers(target_api: NinjaAPI) -> None:
target_api.add_router("/system", build_system_router(), tags=["system"])
target_api.add_router("/user", build_accounts_router(), tags=["user"])
target_api.add_router("/audit", build_audit_router(), tags=["audit"])
target_api.add_router("/servers", build_servers_router(), tags=["servers"])
target_api.add_router("/users", build_users_router(), tags=["users"])
target_api.add_router("/keys", build_keys_router(), tags=["keys"])
target_api.add_router("/access-requests", build_access_router(), tags=["access"])
target_api.add_router("/telemetry", build_telemetry_router(), tags=["telemetry"])
target_api.add_router("/agent", build_agent_router(), tags=["agent"])
target_api.add_router("/system", build_system_router(), tags=["System"])
target_api.add_router("/user", build_accounts_router(), tags=["Account Context"])
target_api.add_router("/audit", build_audit_router(), tags=["Audit Logging"])
target_api.add_router("/servers", build_servers_router(), tags=["Servers"])
target_api.add_router("/users", build_users_router(), tags=["User Directory"])
target_api.add_router("/keys", build_keys_router(), tags=["SSH Keys"])
target_api.add_router("/access-requests", build_access_router(), tags=["Access Requests"])
target_api.add_router("/telemetry", build_telemetry_router(), tags=["Telemetry"])
target_api.add_router("/agent", build_agent_router(), tags=["Agent"])
def build_api(**kwargs) -> NinjaAPI:

View File

@@ -78,6 +78,7 @@ def build_router() -> Router:
- If user has global `access.view_accessrequest`, returns all requests.
- Otherwise, returns only objects with `access.view_accessrequest` object permission.
Filters: status, server_id, requester_id (requester_id is honored only with global view).
Rationale: powers the access request queue and auditing views.
"""
require_authenticated(request)
user = request.user
@@ -107,6 +108,9 @@ def build_router() -> Router:
Auth: required.
Permissions: requires global `access.add_accessrequest`.
Side effects: grants owner object perms on the new request.
Behavior: creates a pending access request; it does not grant access
until approved. Optional expires_at defines the requested access window.
Rationale: this is the entry point for delegating server access.
"""
require_authenticated(request)
if not request.user.has_perm("access.add_accessrequest"):
@@ -133,6 +137,7 @@ def build_router() -> Router:
Auth: required.
Permissions: requires `access.view_accessrequest` on the object.
Rationale: used for request detail views and approval workflows.
"""
require_authenticated(request)
try:
@@ -153,6 +158,9 @@ def build_router() -> Router:
- Admin/operator (global change) can set status to approved/denied/revoked/cancelled and
update expires_at.
- Non-admin can only set status to cancelled, and only while pending.
Side effects: updates object permissions for server visibility when
approvals or revocations occur.
Rationale: this is the core approval/denial path for access control.
"""
require_authenticated(request)
try:
@@ -195,24 +203,6 @@ def build_router() -> Router:
sync_server_view_perm(access_request)
return _request_to_out(access_request)
@router.delete("/{request_id}", response={204: None})
def delete_request(request: HttpRequest, request_id: int):
"""Delete an access request.
Auth: required.
Permissions: requires `access.delete_accessrequest` on the object.
"""
require_authenticated(request)
try:
access_request = AccessRequest.objects.get(id=request_id)
except AccessRequest.DoesNotExist:
raise HttpError(404, "Not Found")
if not request.user.has_perm("access.delete_accessrequest", access_request):
raise HttpError(403, "Forbidden")
access_request.delete()
sync_server_view_perm(access_request)
return 204, None
return router

View File

@@ -20,7 +20,14 @@ def build_router() -> Router:
@router.get("/me", response=UserSchema)
def me(request: HttpRequest):
"""Return the current authenticated user's profile."""
"""Return the authenticated user's profile and role context.
Auth: required (session or JWT). Used by the UI to build navigation,
display the user identity, and decide which actions are enabled.
Fields: returns only the minimal identity and privilege flags needed
by the client; no secrets or permissions lists are exposed here.
Rationale: keeps the client-side state aligned with the session user.
"""
require_authenticated(request)
user = request.user
return {

View File

@@ -88,7 +88,18 @@ def build_router() -> Router:
@router.post("/enroll", response=AgentEnrollOut, auth=None)
@csrf_exempt
def enroll_agent(request: HttpRequest, payload: AgentEnrollIn = Body(...)):
"""Enroll a server agent using a one-time token."""
"""Enroll a server agent using a one-time enrollment token.
Auth: token only (no session/JWT); mTLS is not yet available until
enrollment completes.
Inputs: enrollment token + CSR from the agent, optional host/IP hints.
Behavior:
- Creates a Server record (agent is the source of truth for host/IP).
- Marks the token as used (single-use).
- Signs the CSR with the active Agent CA and returns client cert + CA.
Rationale: this is the only supported server onboarding flow. If this
endpoint is removed, agents cannot bootstrap mTLS credentials.
"""
token_value = (payload.token or "").strip()
if not token_value:
raise HttpError(422, "Token required")
@@ -138,7 +149,14 @@ def build_router() -> Router:
@router.get("/servers/{server_id}/authorized-keys", response=List[AuthorizedKeyOut])
def authorized_keys(request: HttpRequest, server_id: int):
"""Return authorized public keys for a server (admin or operator)."""
"""Resolve the effective authorized_keys list for a server.
Auth: required (admin/operator via API).
Permissions: requires view access to servers, keys, and access requests.
Behavior: combines approved access requests with active SSH keys to
produce the exact key list the agent should deploy to the server.
Rationale: this is the policy enforcement point for per-user access.
"""
require_perms(
request,
"servers.view_server",
@@ -175,7 +193,13 @@ def build_router() -> Router:
@router.post("/servers/{server_id}/sync-report", response=SyncReportOut, auth=None)
@csrf_exempt
def sync_report(request: HttpRequest, server_id: int, payload: SyncReportIn = Body(...)):
"""Record an agent sync report for a server (admin or operator)."""
"""Record an agent sync report for a server.
Auth: mTLS expected at the edge (no session/JWT).
Behavior: stores a telemetry event with counts of applied/revoked keys.
Rationale: provides an audit trail of enforcement actions without
requiring full log ingestion for every sync cycle.
"""
try:
server = Server.objects.get(id=server_id)
except Server.DoesNotExist:
@@ -197,7 +221,14 @@ def build_router() -> Router:
@router.post("/servers/{server_id}/logs", response=LogIngestOut, auth=None)
@csrf_exempt
def ingest_logs(request: HttpRequest, server_id: int, payload: List[LogEventIn] = Body(...)):
"""Accept log batches from agents (mTLS required at the edge)."""
"""Accept log batches from agents for audit collection.
Auth: mTLS expected at the edge (no session/JWT).
Behavior: accepts structured log events for later storage and indexing.
Storage: raw logs are persisted separately per-server (SQLite shards),
not in the primary Postgres database.
Rationale: this is the ingestion pipe for security audit logging.
"""
try:
Server.objects.get(id=server_id)
except Server.DoesNotExist:
@@ -208,7 +239,13 @@ def build_router() -> Router:
@router.post("/servers/{server_id}/heartbeat", response=SyncReportOut, auth=None)
@csrf_exempt
def heartbeat(request: HttpRequest, server_id: int, payload: AgentHeartbeatIn = Body(...)):
"""Update server host metadata (mTLS required at the edge)."""
"""Update server host metadata (hostname/IPs) reported by the agent.
Auth: mTLS expected at the edge (no session/JWT).
Behavior: updates hostname/IPv4/IPv6 when they change (e.g., DHCP).
Conflict: unique constraints are enforced; conflicts return 409.
Rationale: keeps the server inventory accurate without manual edits.
"""
try:
server = Server.objects.get(id=server_id)
except Server.DoesNotExist:

View File

@@ -47,7 +47,14 @@ def build_router() -> Router:
@router.get("/event-types", response=List[AuditEventTypeSchema])
def list_event_types(request: HttpRequest):
"""List audit event types and their default severity."""
"""List audit event types used by the platform audit log.
Auth: required.
Permissions: requires global `audit.view_auditeventtype`.
Behavior: returns the canonical event taxonomy (key, title, severity).
Rationale: the admin UI and audit filters use this to map log entries
to human-readable categories and severity defaults.
"""
require_perms(request, "audit.view_auditeventtype")
qs: QuerySet[AuditEventType] = AuditEventType.objects.all()
return [
@@ -63,7 +70,16 @@ def build_router() -> Router:
@router.get("/logs", response=List[AuditLogSchema])
def list_logs(request: HttpRequest, filters: LogsQuery = Query(...)):
"""List audit logs with optional filters and pagination."""
"""List application audit log entries with filters and pagination.
Auth: required.
Permissions: requires global `audit.view_auditlog`.
Filters: severity, actor_id, event_type_key, source.
Pagination: limit + offset.
Scope: this is the Keywarden app audit trail (who changed what), not
the server OS log ingestion stream stored by the agent.
Rationale: used by the audit UI and for administrative forensics.
"""
require_perms(request, "audit.view_auditlog")
qs: QuerySet[AuditLog] = AuditLog.objects.select_related("event_type", "actor").all()
if filters.severity:

View File

@@ -76,6 +76,7 @@ def build_router() -> Router:
- If user has global `keys.view_sshkey`, returns all keys.
- Otherwise, returns only objects with `keys.view_sshkey` object permission.
Filter: user_id (honored only with global view).
Rationale: powers the key inventory UI and lets admins audit key usage.
"""
require_authenticated(request)
user = request.user
@@ -104,6 +105,7 @@ def build_router() -> Router:
- Default owner is the current user.
- If caller has global `keys.add_sshkey` and `keys.view_sshkey`, they may specify user_id.
Side effects: grants owner object perms on the new key.
Rationale: keys are the core authorization material synced to servers.
"""
require_authenticated(request)
if not request.user.has_perm("keys.add_sshkey"):
@@ -140,6 +142,7 @@ def build_router() -> Router:
Auth: required.
Permissions: requires `keys.view_sshkey` on the object.
Rationale: used by key detail views and server access debugging.
"""
require_authenticated(request)
try:
@@ -156,6 +159,7 @@ def build_router() -> Router:
Auth: required.
Permissions: requires `keys.change_sshkey` on the object.
Rationale: allows key rotation and revocation without deletion.
"""
require_authenticated(request)
try:
@@ -187,6 +191,7 @@ def build_router() -> Router:
Auth: required.
Permissions: requires `keys.delete_sshkey` on the object.
Behavior: sets is_active false and revoked_at if key is active.
Rationale: removes key access while preserving auditability.
"""
require_authenticated(request)
try:

View File

@@ -29,7 +29,13 @@ def build_router() -> Router:
@router.get("/", response=List[ServerOut])
def list_servers(request: HttpRequest):
"""List servers visible to authenticated users."""
"""List servers the caller can view.
Auth: required.
Permissions: requires `servers.view_server` globally or per-object.
Behavior: returns only servers the user can see via object perms.
Rationale: drives the server dashboard and access-aware navigation.
"""
require_perms(request, "servers.view_server")
if request.user.has_perm("servers.view_server"):
servers = Server.objects.all()
@@ -55,7 +61,13 @@ def build_router() -> Router:
@router.get("/{server_id}", response=ServerOut)
def get_server(request: HttpRequest, server_id: int):
"""Get server details by id."""
"""Get a server record by id.
Auth: required.
Permissions: requires `servers.view_server` globally or per-object.
Rationale: used by server detail views and API clients inspecting
server metadata (hostname/IPs populated by the agent).
"""
require_perms(request, "servers.view_server")
try:
server = Server.objects.get(id=server_id)
@@ -77,7 +89,14 @@ def build_router() -> Router:
@router.patch("/{server_id}", response=ServerOut)
def update_server(request: HttpRequest, server_id: int, payload: ServerUpdate):
"""Update server display name (admin only)."""
"""Update the server display name (admin only).
Auth: required.
Permissions: requires `servers.change_server`.
Behavior: only display_name is editable via API; host/IP data is owned
by the agent heartbeat to avoid conflicting sources of truth.
Rationale: allows human-friendly naming without bypassing enrollment.
"""
require_perms(request, "servers.change_server")
if payload.display_name is None:
raise HttpError(422, {"detail": "No fields provided."})

View File

@@ -14,7 +14,14 @@ def build_router() -> Router:
@router.get("/health", response=HealthResponse)
def health(request) -> HealthResponse:
"""Health check endpoint for service monitoring."""
"""Return application liveness for internal monitoring.
Auth: required (session or JWT). This is intentionally protected to avoid
exposing internal status to unauthenticated callers.
Behavior: returns a static {"status": "ok"} if the app stack is reachable.
Rationale: used by uptime checks and deployments to confirm the API
process is running and can authenticate requests.
"""
require_authenticated(request)
return {"status": "ok"}

View File

@@ -71,7 +71,13 @@ def build_router() -> Router:
@router.get("/", response=List[TelemetryOut])
def list_events(request: HttpRequest, filters: TelemetryQuery = Query(...)):
"""List telemetry events with filters (admin or operator)."""
"""List telemetry events emitted by the platform and agents.
Auth: required.
Permissions: requires `telemetry.view_telemetryevent`.
Filters: event_type, server_id, user_id, success.
Rationale: supports operational dashboards and audit-style timelines.
"""
require_perms(request, "telemetry.view_telemetryevent")
qs = TelemetryEvent.objects.order_by("-created_at")
if filters.event_type:
@@ -87,7 +93,14 @@ def build_router() -> Router:
@router.post("/", response=TelemetryOut)
def create_event(request: HttpRequest, payload: TelemetryCreateIn):
"""Create a telemetry event entry (admin or operator)."""
"""Create a telemetry event entry.
Auth: required.
Permissions: requires `telemetry.add_telemetryevent`.
Behavior: validates server/user references and normalizes source.
Rationale: used by internal automation; if external clients are not
expected to emit telemetry, this endpoint can be restricted further.
"""
require_perms(request, "telemetry.add_telemetryevent")
server = None
if payload.server_id:
@@ -115,7 +128,12 @@ def build_router() -> Router:
@router.get("/summary", response=TelemetrySummaryOut)
def summary(request: HttpRequest):
"""Return a high-level telemetry summary (admin or operator)."""
"""Return a high-level success/failure summary.
Auth: required.
Permissions: requires `telemetry.view_telemetryevent`.
Rationale: feeds dashboard widgets without pulling full event lists.
"""
require_perms(request, "telemetry.view_telemetryevent")
totals = TelemetryEvent.objects.aggregate(
total=Count("id"),

View File

@@ -53,7 +53,15 @@ def build_router() -> Router:
@router.post("/", response=UserDetailOut)
def create_user(request: HttpRequest, payload: UserCreateIn):
"""Create a user with role and password (admin or operator)."""
"""Create a platform user and assign a Keywarden role.
Auth: required.
Permissions: requires `auth.add_user` (admin/operator).
Behavior: uses email as username, hashes the password, and assigns a
role which maps to Keywarden group permissions.
Rationale: enables automation and external admin workflows; mirrors
the admin UI user creation flow.
"""
require_perms(request, "auth.add_user")
User = get_user_model()
email = payload.email.strip().lower()
@@ -79,7 +87,13 @@ def build_router() -> Router:
@router.get("/", response=List[UserListOut])
def list_users(request: HttpRequest, pagination: UsersQuery = Query(...)):
"""List users with pagination (admin or operator)."""
"""List users for administrative visibility and access management.
Auth: required.
Permissions: requires `auth.view_user`.
Pagination: limit + offset.
Rationale: used by admin UI and automation to audit user access.
"""
require_perms(request, "auth.view_user")
User = get_user_model()
qs = User.objects.order_by("id")[pagination.offset : pagination.offset + pagination.limit]
@@ -95,7 +109,12 @@ def build_router() -> Router:
@router.get("/{user_id}", response=UserDetailOut)
def get_user(request: HttpRequest, user_id: int):
"""Get user details by id (admin or operator)."""
"""Fetch a single user record for inspection.
Auth: required.
Permissions: requires `auth.view_user`.
Rationale: used by admin detail views and automation scripts.
"""
require_perms(request, "auth.view_user")
User = get_user_model()
try:
@@ -111,7 +130,13 @@ def build_router() -> Router:
@router.patch("/{user_id}", response=UserDetailOut)
def update_user(request: HttpRequest, user_id: int, payload: UserUpdateIn):
"""Update user fields such as role, email, or status (admin only)."""
"""Update user identity, role, password, or activation state.
Auth: required.
Permissions: requires `auth.change_user` (admin).
Side effects: role changes update Keywarden role/group mappings.
Rationale: required for role delegation and account lifecycle control.
"""
require_perms(request, "auth.change_user")
if payload.email is None and payload.password is None and payload.role is None and payload.is_active is None:
raise HttpError(422, {"detail": "No fields provided."})
@@ -143,18 +168,6 @@ def build_router() -> Router:
"is_active": user.is_active,
}
@router.delete("/{user_id}", response={204: None})
def delete_user(request: HttpRequest, user_id: int):
"""Delete a user by id (admin only)."""
require_perms(request, "auth.delete_user")
User = get_user_model()
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist:
raise HttpError(404, "Not Found")
user.delete()
return 204, None
return router