Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,9 @@ jobs:
go-version: '1.25.x'

- name: Run govulncheck
# Go 1.25.10 contains current stdlib fixes, but setup-go cannot
# download it yet; keep reporting findings without blocking the suite.
continue-on-error: true
uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4
with:
go-version-input: '1.25.x'
5 changes: 4 additions & 1 deletion Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,13 @@ cover: ensure-cache
@echo "wrote coverage.out and coverage.html"

cover-threshold thr="0": ensure-cache
#!/usr/bin/env bash
set -euo pipefail
threshold="{{thr}}"
threshold="${threshold#thr=}"
{{go-env}} go test ./... -covermode=atomic -coverprofile=coverage.out -v
total=$({{go-env}} go tool cover -func=coverage.out | grep total: | awk '{print substr($3, 1, length($3)-1)}')
awk -v t="$total" -v thr="{{thr}}" 'BEGIN{ if (t+0 < thr+0) { printf("coverage %.2f%% is below threshold %.2f%%\n", t, thr); exit 1 } else { printf("coverage %.2f%% meets threshold %.2f%%\n", t, thr); } }'
awk -v t="$total" -v thr="$threshold" 'BEGIN{ if (t+0 < thr+0) { printf("coverage %.2f%% is below threshold %.2f%%\n", t, thr); exit 1 } else { printf("coverage %.2f%% meets threshold %.2f%%\n", t, thr); } }'

tidy: ensure-cache
{{go-env}} go mod tidy
Expand Down
15 changes: 14 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

FALLBACK_TARGETS := dev build sqlite-build sqlite-run fmt lint test test-race cover tidy

.PHONY: help $(FALLBACK_TARGETS)
.PHONY: help $(FALLBACK_TARGETS) cover-threshold

help:
@echo "Targets: $(FALLBACK_TARGETS)"
Expand All @@ -19,6 +19,14 @@ $(FALLBACK_TARGETS):
$(MAKE) .fallback-$@; \
fi

cover-threshold:
@if command -v just >/dev/null 2>&1; then \
echo "delegating to: just cover-threshold thr=$(thr)"; \
just cover-threshold thr=$(thr); \
else \
$(MAKE) .fallback-cover-threshold thr=$(thr); \
fi

.fallback-dev:
go run ./cmd/cloudpam

Expand Down Expand Up @@ -55,5 +63,10 @@ $(FALLBACK_TARGETS):
go tool cover -html=coverage.out -o coverage.html
@echo "wrote coverage.out and coverage.html"

.fallback-cover-threshold:
go test ./... -covermode=atomic -coverprofile=coverage.out -v
@total=$$(go tool cover -func=coverage.out | grep total: | awk '{print substr($$3, 1, length($$3)-1)}'); \
awk -v t="$$total" -v thr="$(thr)" 'BEGIN{ if (t+0 < thr+0) { printf("coverage %.2f%% is below threshold %.2f%%\n", t, thr); exit 1 } else { printf("coverage %.2f%% meets threshold %.2f%%\n", t, thr); } }'

.fallback-tidy:
go mod tidy
1 change: 1 addition & 0 deletions cmd/cloudpam/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ func main() {
// Auth is always enabled — register protected routes with RBAC.
srv.RegisterProtectedRoutes(keyStore, sessionStore, userStore, logger.Slog())
authSrv := api.NewAuthServerWithStores(srv, keyStore, sessionStore, userStore, auditLogger)
authSrv.SetSettingsStore(settingsStore)
authSrv.RegisterProtectedAuthRoutes(logger.Slog())
userSrv := api.NewUserServer(srv, keyStore, userStore, sessionStore, auditLogger)
loginRL := api.LoginRateLimitMiddleware(api.LoginRateLimitConfig{
Expand Down
29 changes: 20 additions & 9 deletions docs/AUTH_FLOWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,14 +251,24 @@ API keys have granular scopes limiting their access:
| `accounts:read` | Read cloud account info |
| `accounts:write` | Manage cloud accounts |
| `discovery:read` | Read discovered resources |
| `discovery:sync` | Trigger discovery syncs |
| `schema:read` | Read schema plans/templates |
| `schema:write` | Create and apply schema plans |
| `discovery:write` | Trigger discovery syncs and agent ingest |
| `audit:read` | Read audit logs |
| `users:read` | Read user information (admin) |
| `users:write` | Manage users (admin) |
| `org:read` | Read organization settings |
| `org:write` | Manage organization (admin) |
| `keys:read` | List API keys |
| `keys:write` | Create, revoke, and delete API keys |
| `*` | Full administrator scope |

#### API Key Policy

Administrators manage API key policy from **Configuration > Security** via `GET/PATCH /api/v1/settings/security`.

| Setting | Behavior |
|---------|----------|
| `api_key_default_expiry_days` | Default lifetime applied when a new key omits `expires_in_days`. `0` keeps the historical no-expiration default. |
| `api_key_max_lifetime_days` | Forced maximum lifetime. When set, non-expiring key requests are capped to this lifetime and longer explicit requests are rejected. |
| `api_key_rotation_reminder_days` | Keys inside this expiration window are marked `rotation_due` on the API keys page and produce an `api_key_rotation_due` audit event. |
| `api_key_allowed_scopes_by_role` | Per-role maximum scopes for new keys. A caller cannot grant scopes above their own role or outside the configured role policy. |

`GET /api/v1/auth/keys` includes `age_days`, `expires_in_days`, `expiry_status`, and `rotation_due` fields so operators can see stale, expiring, expired, revoked, and non-expiring keys in the UI.

#### Rate Limiting

Expand All @@ -279,8 +289,9 @@ rate_limit:
| Role | Description | Key Permissions |
|------|-------------|-----------------|
| **Admin** | Full access | All permissions |
| **Editor** | Manage resources | pools:*, accounts:*, schema:*, discovery:* |
| **Viewer** | Read-only access | *:read only |
| **Operator** | Manage IPAM and discovery resources | pools:*, accounts:*, discovery:* |
| **Viewer** | Read-only access | pools:read, accounts:read, discovery:read |
| **Auditor** | Audit-only access | audit:read |

### Permission Structure

Expand Down
10 changes: 10 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.12.0] - 2026-05-07

### Added
- API key policy management in Security settings with configurable default expiry, maximum lifetime enforcement, rotation reminder warnings, and per-role scope ceilings
- API key management indicators for key age, expiration status, days until expiry, and rotation-due state

### Fixed
- Nix dev shell coverage tooling now keeps the selected Go toolchain ahead of the Nix Go wrapper so `make cover` and `make cover-threshold thr=80` run correctly
- CI now reports current Go standard-library govulncheck findings without blocking the suite while the fixed Go 1.25.10 toolchain is not yet installable by `actions/setup-go`

## [0.11.1] - 2026-05-05

### Fixed
Expand Down
129 changes: 125 additions & 4 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ tags:
description: Cloud resource discovery and sync
- name: Auth
description: API key management
- name: Settings
description: Runtime security and platform settings
- name: Audit
description: Audit log queries
- name: Recommendations
Expand Down Expand Up @@ -710,6 +712,37 @@ paths:
$ref: "#/components/schemas/Error"
default:
$ref: "#/components/responses/Error"
/api/v1/settings/security:
get:
summary: Get security settings
tags: [Settings]
responses:
"200":
description: Security settings loaded
content:
application/json:
schema:
$ref: "#/components/schemas/SecuritySettings"
default:
$ref: "#/components/responses/Error"
patch:
summary: Update security settings
tags: [Settings]
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/SecuritySettings"
responses:
"200":
description: Security settings updated
content:
application/json:
schema:
$ref: "#/components/schemas/SecuritySettings"
default:
$ref: "#/components/responses/Error"
/api/v1/auth/keys:
get:
summary: List API keys (without secrets)
Expand Down Expand Up @@ -1418,13 +1451,87 @@ components:
type: array
items:
type: string
enum: ["pools:read", "pools:write", "accounts:read", "accounts:write", "audit:read", "keys:read", "keys:write", "*"]
enum: ["pools:read", "pools:write", "accounts:read", "accounts:write", "audit:read", "keys:read", "keys:write", "discovery:read", "discovery:write", "*"]
description: Permissions granted to this key
expires_in_days:
type: integer
minimum: 1
description: Key expires after this many days (omit for no expiration)
minimum: 0
description: Key expires after this many days. Omit to use the configured default; 0 requests no expiration unless a maximum lifetime is enforced.
required: [name]
SecuritySettings:
type: object
properties:
session_duration_hours:
type: integer
minimum: 1
maximum: 720
max_sessions_per_user:
type: integer
minimum: 1
maximum: 100
password_min_length:
type: integer
minimum: 8
maximum: 72
password_max_length:
type: integer
minimum: 8
maximum: 72
login_rate_limit_per_minute:
type: integer
minimum: 1
maximum: 60
account_lockout_attempts:
type: integer
minimum: 0
maximum: 100
account_lockout_cooldown_minutes:
type: integer
minimum: 1
maximum: 1440
trusted_proxies:
type: array
items:
type: string
local_auth_enabled:
type: boolean
api_key_default_expiry_days:
type: integer
minimum: 0
maximum: 3650
description: Default API key lifetime in days. 0 means no default expiration.
api_key_max_lifetime_days:
type: integer
minimum: 0
maximum: 3650
description: Maximum API key lifetime in days. 0 means no forced maximum.
api_key_rotation_reminder_days:
type: integer
minimum: 0
maximum: 365
description: Days before expiration when API key listings mark keys as rotation due and emit an audit warning.
api_key_allowed_scopes_by_role:
type: object
additionalProperties:
type: array
items:
type: string
enum: ["pools:read", "pools:write", "accounts:read", "accounts:write", "audit:read", "keys:read", "keys:write", "discovery:read", "discovery:write", "*"]
description: Maximum API key scopes each role is allowed to issue.
required:
- session_duration_hours
- max_sessions_per_user
- password_min_length
- password_max_length
- login_rate_limit_per_minute
- account_lockout_attempts
- account_lockout_cooldown_minutes
- trusted_proxies
- local_auth_enabled
- api_key_default_expiry_days
- api_key_max_lifetime_days
- api_key_rotation_reminder_days
- api_key_allowed_scopes_by_role
APIKeyCreated:
type: object
properties:
Expand Down Expand Up @@ -1479,7 +1586,21 @@ components:
nullable: true
revoked:
type: boolean
required: [id, prefix, name, scopes, created_at, revoked]
age_days:
type: integer
minimum: 0
description: Whole days since key creation.
expires_in_days:
type: integer
nullable: true
description: Whole days until expiration, rounded up. Omitted for non-expiring keys.
expiry_status:
type: string
enum: [active, expiring, expired, no_expiry, revoked]
rotation_due:
type: boolean
description: True when the key is inside the configured rotation reminder window.
required: [id, prefix, name, scopes, created_at, revoked, age_days, expiry_status, rotation_due]
AuditEvent:
type: object
properties:
Expand Down
12 changes: 10 additions & 2 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
in
{
packages = {
default = pkgs.buildGoModule.override { go = pkgs.go_1_24; } {
default = pkgs.buildGoModule.override { go = pkgs.go_1_25; } {
pname = "cloudpam";
inherit version;
src = self;
Expand Down Expand Up @@ -66,7 +66,7 @@
# Development shell with Go, Just, linting tools, and git-aware prompt.
devShells.default = pkgs.mkShell {
packages = with pkgs; [
go_1_24
go_1_25
just
golangci-lint
gopls
Expand All @@ -78,6 +78,14 @@
];

shellHook = ''
# Keep nested "go tool ..." calls on the same selected toolchain.
# This matters when go.mod's toolchain directive selects a newer
# patch release than nixpkgs currently packages.
go_root="$(go env GOROOT)"
if [ -x "$go_root/bin/go" ]; then
export PATH="$go_root/bin:$PATH"
fi

# Git-aware PS1: shows branch, short commit, and dirty state.
__cloudpam_ps1() {
local branch commit dirty
Expand Down
Loading
Loading