diff --git a/agent/.gitignore b/agent/.gitignore new file mode 100644 index 0000000..7d8b3a6 --- /dev/null +++ b/agent/.gitignore @@ -0,0 +1 @@ +keywarden-agent \ No newline at end of file diff --git a/app/keywarden/api/main.py b/app/keywarden/api/main.py index cf97bb1..e7b7c7d 100644 --- a/app/keywarden/api/main.py +++ b/app/keywarden/api/main.py @@ -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: diff --git a/app/keywarden/api/routers/access.py b/app/keywarden/api/routers/access.py index 688e7b7..87800d8 100644 --- a/app/keywarden/api/routers/access.py +++ b/app/keywarden/api/routers/access.py @@ -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 diff --git a/app/keywarden/api/routers/accounts.py b/app/keywarden/api/routers/accounts.py index d4ec3d3..b2a121a 100644 --- a/app/keywarden/api/routers/accounts.py +++ b/app/keywarden/api/routers/accounts.py @@ -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 { diff --git a/app/keywarden/api/routers/agent.py b/app/keywarden/api/routers/agent.py index 6a9870b..3215b01 100644 --- a/app/keywarden/api/routers/agent.py +++ b/app/keywarden/api/routers/agent.py @@ -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: diff --git a/app/keywarden/api/routers/audit.py b/app/keywarden/api/routers/audit.py index 52c4733..78a7187 100644 --- a/app/keywarden/api/routers/audit.py +++ b/app/keywarden/api/routers/audit.py @@ -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: diff --git a/app/keywarden/api/routers/keys.py b/app/keywarden/api/routers/keys.py index bba28a8..4ae1377 100644 --- a/app/keywarden/api/routers/keys.py +++ b/app/keywarden/api/routers/keys.py @@ -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: diff --git a/app/keywarden/api/routers/servers.py b/app/keywarden/api/routers/servers.py index 581f495..81f1845 100644 --- a/app/keywarden/api/routers/servers.py +++ b/app/keywarden/api/routers/servers.py @@ -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."}) diff --git a/app/keywarden/api/routers/system.py b/app/keywarden/api/routers/system.py index 7e90f75..5c25613 100644 --- a/app/keywarden/api/routers/system.py +++ b/app/keywarden/api/routers/system.py @@ -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"} diff --git a/app/keywarden/api/routers/telemetry.py b/app/keywarden/api/routers/telemetry.py index 11cd6c5..aa2b7fc 100644 --- a/app/keywarden/api/routers/telemetry.py +++ b/app/keywarden/api/routers/telemetry.py @@ -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"), diff --git a/app/keywarden/api/routers/users.py b/app/keywarden/api/routers/users.py index 7660904..a353984 100644 --- a/app/keywarden/api/routers/users.py +++ b/app/keywarden/api/routers/users.py @@ -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