The server side of Power Manage, providing the API, web UI, agent registration, and real-time agent communication. It consists of two binaries:
- Control Server — API for the web UI, user management, agent registration (token validation + certificate signing), PostgreSQL event store
- Gateway Server — Bidirectional streaming endpoint for agents, dispatches actions in real time (stateless, no database)
- Indexer — Full-text search indexer, reads from PostgreSQL and writes to Valkey RediSearch
Security: the threat model, trust boundaries, CA-compromise surface, and vulnerability-disclosure process are documented in SECURITY.md.
┌───────────────────────┐
Web UI / CLI ───────▶│ Control Server │
(JWT auth) │ :8081 │
│ │
│ - Connect-RPC API │
│ - Agent registration │
│ - Certificate signing│
│ - InternalService │
└───┬──────────────┬────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ PostgreSQL │ │ Valkey │
│ │ │ │
│ - Event store│ │ - Asynq tasks│
│ - Projections│ │ - device:* │
└──────┬───────┘ │ - control:* │
│ │ - search idx │
▼ └──┬────────┬──┘
┌──────────────┐ │ │
│ Indexer │─────┘ │
│ (search) │ │
└──────────────┘ ▼
┌──────────────────────┐
Agents ─────────────▶│ Gateway Server │
(mTLS) │ :8080 │
│ │
│ - Streaming RPC │
│ - Asynq workers │
│ - Connect-RPC proxy │
└──────────────────────┘
All state changes are recorded as immutable events in a single events table. PostgreSQL trigger functions project events into read-optimized *_projection tables automatically. Queries read from projections, never from the event store directly.
Inter-service communication uses Asynq (Valkey-backed task queue) — when the Control Server dispatches an action, it enqueues an Asynq task to the device's queue (device:<id>). The Gateway runs per-device Asynq workers that pick up tasks and stream them to connected agents. Agent responses flow back via the control:inbox queue. Credential-bearing operations (LUKS keys, LPS passwords) are proxied via Connect-RPC (InternalService) to avoid plaintext secrets in the queue.
Device-origin binding (ADR 0005): the gateway peer cert is shared and carries no per-gateway identity, so every device-origin request (InternalService) and event (control:inbox) self-asserts a gateway_id that control cross-checks against the device→gateway routing registry the agent's own mTLS heartbeat populated (registry.CheckDeviceGatewayBinding, fail-closed). A compromised gateway therefore cannot pull another device's secrets or forge its events; the events audit trail is DB-enforced append-only (migration 011). The binding is bypassed only when no resolver is wired (single-gateway / non-HA).
See the Control Server README for details on the event model, API endpoints, and authorization policies.
| Package | Purpose |
|---|---|
internal/actionparams |
Per-action-type parameter validation, schedule serialization, and proto/wire conversion shared by the action and dispatch handlers. A single proto-reflection registry (paramsFieldByActionType + ExtractParamsMsg/ParamsMatchType) is the one source of the ActionType → params-oneof mapping — adding an action type touches one entry, not six switches. Event payloads are proto-native (typed payloads.*, protojson for proto-derived JSONB); see ADR 0004 |
internal/api |
Control Server RPC handlers (actions, devices, users, tokens, assignments, roles, user groups, identity providers, SCIM, TOTP, compliance, etc.) |
internal/asynqutil |
Asynq task-queue helpers shared between the control inbox worker and the per-device gateway dispatchers |
internal/auth |
JWT bearer authentication, dynamic-RBAC permission map (Go authorizer in authorizer.go), TOTP 2FA, rate limiting, self-scope enforcement |
internal/ca |
Internal CA for signing agent certificates, certificate renewal verification, action payloads, and CA rotation via trust bundles |
internal/config |
Configuration loading (gateway) |
internal/connection |
Gateway connection manager — tracks connected agents, routes messages |
internal/control |
Asynq inbox worker — processes gateway-to-control task queue (control:inbox) |
internal/crypto |
AES-GCM encryption for secrets (identity provider client secrets, LUKS keys, LPS passwords) |
internal/gateway |
Per-device Asynq workers and task handlers for control-to-gateway dispatch |
internal/gateway/registry |
Multi-gateway device→gateway routing registry (Valkey-backed) |
internal/handler |
Gateway RPC handlers (agent streaming, Connect-RPC proxy to control, auto-update info) |
internal/idp |
OIDC identity provider SSO (authorization code flow, token exchange, user linking) |
internal/middleware |
HTTP middleware (request ID injection, security headers, logging) |
internal/mtls |
mTLS setup (RequireAndVerifyClientCert, TLS 1.3), extracts device identity from client certs, enforces SPIFFE peer-class URI SANs (agent / gateway / control) on each listener |
internal/projectors |
Go event-listener projector ports (tracker #107) — pure decoders + listener factories + sqlc-driven applies that replace the older PL/pgSQL projector functions |
internal/resolution |
Assignment resolution engine (user/user_group/device/device_group targets) |
internal/scim |
SCIM v2 provisioning server (REST endpoints for user/group sync from external IdPs) |
internal/search |
Full-text search indexer using Valkey RediSearch — FT index management, Asynq reindex workers, cascade updates |
internal/store |
PostgreSQL event store, migrations, sqlc queries |
internal/taskqueue |
Asynq task queue client, task type constants, payload structs |
internal/terminal |
Session token store (Valkey-backed) for remote terminal sessions |
internal/testutil |
Test helpers — PostgreSQL testcontainers, test entity factories, auth context injection |
The Control Server exposes a Connect-RPC API (pm.v1.ControlService); the Gateway Server exposes pm.v1.AgentService. The full proto definitions live in sdk/proto/pm/v1. The section headings below are categorical, not exhaustive — refer to the proto for the authoritative method list.
| Method | Description |
|---|---|
Login |
Authenticate with email/password, returns JWT access + refresh tokens in the response body. Clients pass tokens via the Authorization: Bearer header on subsequent requests. |
RefreshToken |
Exchange refresh token (from request body) for a new token pair with rotation. |
Logout |
Revoke the presented refresh token. |
GetCurrentUser |
Return the authenticated user's profile from JWT claims. |
JWTs are signed with HS256 only — alg-confusion tokens (none, HS384/HS512,
RS256) are rejected. Login returns the same generic invalid credentials for a
wrong password, a non-existent account, and a disabled account, so a
credential holder can't probe account state.
Certificate revocation (mTLS plane). Revoked agent/gateway/control
certificate fingerprints are published to a shared Valkey CRL. Both the
gateway's agent listener and the internal mTLS listeners consult it: the
control server's InternalService (credential-bearing proxy RPCs) and the
gateway's control-class GatewayService reject a revoked peer cert at connect
time — immediate revocation, not waiting for the cert's natural expiry. The
revocation gate fails closed: if the CRL has not loaded (Valkey unavailable at
boot) the listener refuses connections rather than admitting an unverifiable
cert.
Request-boundary resource bounds. The request boundary is bounded against
resource-exhaustion DoS: list pagination offset is capped (maxListOffset,
deep-OFFSET scans rejected with CodeInvalidArgument); request bodies are size-
capped via connect.WithReadMaxBytes (Control/Internal 8 MiB, Gateway 4 MiB —
over-cap rejected pre-handler with CodeResourceExhausted); the DB pool sets a
statement_timeout (single-query wall-clock bound) and every unary RPC runs
under a request-deadline interceptor; gateway↔control proxy calls carry per-call
deadlines; and event-store rebuild streams in bounded batches instead of buffering
the whole stream.
Rate limiting & client IP. Unauthenticated endpoints (login, refresh,
register, logout, cert renewal, auth-methods lookup) are throttled per client IP;
authenticated RPCs are throttled per user, with a tighter ceiling on heavy
operations (query evaluation, search, projector rebuild, log/osquery fan-out).
Client-IP attribution honours X-Forwarded-For / X-Real-IP only when the
direct peer is in CONTROL_TRUSTED_PROXIES (CIDRs or bare IPs); otherwise the
peer address is used, so proxy headers can't be spoofed to evade per-IP limits.
| Method | Description |
|---|---|
CreateUser |
Create user with email and password. |
GetUser |
Fetch user by ID. Users with GetUser:self can view their own profile; GetUser grants access to any user. |
ListUsers |
Paginated list of all users. |
UpdateUserEmail |
Change a user's email address. Supports :self scoped permission. |
UpdateUserPassword |
Change password. Self-update requires current password; privileged users can reset without it. |
SetUserDisabled |
Enable or disable a user account. |
DeleteUser |
Soft-delete a user. |
| Method | Description |
|---|---|
ListDevices |
Paginated device list with optional status filter (online/offline). Supports my_devices_only flag for users to see only their assigned devices. |
GetDevice |
Fetch device by ID. Online status based on 5-minute heartbeat threshold. |
SetDeviceLabel |
Add or update a key-value label on a device. |
RemoveDeviceLabel |
Remove a label by key. |
AssignDevice |
Assign a device to one or more users. Devices support multi-user assignment. |
UnassignDevice |
Remove a specific user's assignment from a device. |
SetDeviceSyncInterval |
Configure how often the agent syncs (0-1440 minutes). |
DeleteDevice |
Remove a device. Admin-only. |
GetDeviceLpsPasswords |
Retrieve current and historical LPS (Local Password Solution) passwords. |
| Method | Description |
|---|---|
CreateToken |
Generate a 256-bit registration token (SHA256 hash stored). Admins configure one-time/reusable, max uses, expiry. Non-admins get one-time tokens with 7-day expiry. |
GetToken |
Fetch token metadata by ID (plaintext value never returned after creation). |
ListTokens |
Paginated list. Non-admins see only their own tokens. |
RenameToken |
Change token display name. |
SetTokenDisabled |
Enable or disable a token. |
DeleteToken |
Remove a token. |
Manages action definitions. Supports the action types declared in
sdk/proto/pm/v1/actions.proto (canonical list — keep that file as
the source of truth):
Package management: PACKAGE, UPDATE, REPOSITORY, APP_IMAGE, DEB, RPM, FLATPAK
System: SHELL, SYSTEMD (SERVICE), FILE, DIRECTORY
Identity: USER, GROUP, SSH, SSHD, ADMIN_POLICY (sudoers/doas), LPS
Security: ENCRYPTION (LUKS / GELI)
Lifecycle (DispatchInstantAction only): REBOOT, SYNC
| Method | Description |
|---|---|
CreateAction |
Create an action with type-specific parameters. Actions are signed by the CA for agent verification. |
GetAction |
Fetch action by ID. |
ListActions |
Paginated list with optional type filter. |
RenameAction |
Change action name. |
UpdateActionDescription |
Update action description text. |
UpdateActionParams |
Modify type-specific parameters. |
DeleteAction |
Remove an action. |
| Method | Description |
|---|---|
DispatchAction |
Send an action to a specific device. Creates an execution record. |
DispatchToMultiple |
Send an action to multiple devices at once. |
DispatchAssignedActions |
Trigger all assigned actions for a device. |
DispatchActionSet |
Dispatch all actions in a set to a device. |
DispatchDefinition |
Dispatch all action sets in a definition to a device. |
DispatchToGroup |
Dispatch an action to all devices in a group. |
DispatchInstantAction |
Dispatch an ephemeral action (e.g., reboot) without storing it. |
GetExecution |
Fetch execution status and results by ID. |
ListExecutions |
Paginated execution history with device/action filters. |
Ordered collections of actions that can be dispatched together.
| Method | Description |
|---|---|
CreateActionSet |
Create a named action set. |
GetActionSet |
Fetch set with its ordered member actions. |
ListActionSets |
Paginated list. |
RenameActionSet |
Change set name. |
UpdateActionSetDescription |
Update description. |
DeleteActionSet |
Remove set. |
AddActionToSet |
Add an action as a member. |
RemoveActionFromSet |
Remove a member action. |
ReorderActionInSet |
Change the sort order of a member. |
Ordered collections of action sets forming a complete configuration policy.
| Method | Description |
|---|---|
CreateDefinition |
Create a named definition. |
GetDefinition |
Fetch definition with its ordered action sets. |
ListDefinitions |
Paginated list. |
RenameDefinition |
Change name. |
UpdateDefinitionDescription |
Update description. |
DeleteDefinition |
Remove definition. |
AddActionSetToDefinition |
Add an action set as a member. |
RemoveActionSetFromDefinition |
Remove a member. |
ReorderActionSetInDefinition |
Change sort order. |
Static groups with manual membership or dynamic groups with a query language.
| Method | Description |
|---|---|
CreateDeviceGroup |
Create a static or dynamic group. Dynamic groups use a query like (device.labels.environment equals "production"). |
GetDeviceGroup |
Fetch group with member device IDs. |
ListDeviceGroups |
Paginated list. |
ListDeviceGroupsForDevice |
List all groups a device belongs to (static and dynamic). |
RenameDeviceGroup |
Change name. |
UpdateDeviceGroupDescription |
Update description. |
UpdateDeviceGroupQuery |
Change the dynamic query expression. |
DeleteDeviceGroup |
Remove group. |
AddDeviceToGroup |
Add a device to a static group. |
RemoveDeviceFromGroup |
Remove a device from a group. |
ValidateDynamicQuery |
Validate query syntax and return matching device count. |
EvaluateDynamicGroup |
Manually trigger re-evaluation of dynamic group membership. |
SetDeviceGroupSyncInterval |
Set sync interval for all devices in the group. |
Link sources (actions, action sets, definitions, compliance policies) to targets (devices, device groups, users, user groups) with an assignment mode. Batch support: CreateAssignment and DeleteAssignment accept multiple assignments per call.
| Method | Description |
|---|---|
CreateAssignment |
Create source-to-target assignments. Sources: action, action set, definition, compliance policy. Targets: device, device group, user, user group. Modes: REQUIRED (always applied), AVAILABLE (user opt-in), EXCLUDED. Supports batch (multiple assignments per request). Idempotent. |
DeleteAssignment |
Remove assignments. Supports batch. |
ListAssignments |
Paginated list with optional filters. |
GetDeviceAssignments |
Resolve all effective actions and compliance policies for a device (expands groups, definitions, sets, and user/user_group targets). |
GetUserAssignments |
Resolve all assignments targeting a user or their user groups. |
Allow users to opt in or out of AVAILABLE-mode assignments on their devices.
| Method | Description |
|---|---|
SetUserSelection |
Accept or reject an available assignment for a device. |
ListAvailableActions |
List available-mode items for a device with current selection state. |
| Method | Description |
|---|---|
SetupTOTP |
Generate TOTP secret and QR code URI for enrollment. |
VerifyTOTP |
Verify a TOTP code to complete 2FA enrollment. Returns backup codes. |
DisableTOTP |
Disable TOTP 2FA for a user. |
GetTOTPStatus |
Check whether TOTP is enabled for the current user. |
RegenerateBackupCodes |
Generate new backup codes (invalidates previous codes). |
Dynamic role-based access control with per-permission granularity.
| Method | Description |
|---|---|
CreateRole |
Create a custom role with a set of permissions. |
GetRole |
Fetch role by ID with its permission list. |
ListRoles |
Paginated list of all roles. |
UpdateRole |
Update role name, description, or permissions. |
DeleteRole |
Remove a role. |
AssignRoleToUser |
Assign a role to a user. |
RevokeRoleFromUser |
Revoke a role from a user. |
ListPermissions |
List all available permissions with descriptions. |
| Method | Description |
|---|---|
CreateUserGroup |
Create a user group. |
GetUserGroup |
Fetch user group with members and roles. |
ListUserGroups |
Paginated list of all user groups. |
UpdateUserGroup |
Update user group name or description. |
DeleteUserGroup |
Remove a user group. |
AddUserToGroup |
Add a user to a group. |
RemoveUserFromGroup |
Remove a user from a group. |
AssignRoleToUserGroup |
Assign a role to a user group (all members inherit permissions). |
RevokeRoleFromUserGroup |
Revoke a role from a user group. |
ListUserGroupsForUser |
List all groups a user belongs to. |
OIDC identity provider management for SSO authentication.
| Method | Description |
|---|---|
CreateIdentityProvider |
Create an OIDC identity provider (Google, Okta, Azure AD, etc.). |
GetIdentityProvider |
Fetch provider by ID. |
ListIdentityProviders |
Paginated list of configured providers. |
UpdateIdentityProvider |
Update provider settings (name, scopes, auto-create, group mapping, etc.). |
DeleteIdentityProvider |
Remove an identity provider. |
EnableSCIM |
Enable SCIM v2 provisioning for a provider. Returns bearer token and endpoint URL. |
DisableSCIM |
Disable SCIM provisioning for a provider. |
RotateSCIMToken |
Generate a new SCIM bearer token (invalidates the old one). |
| Method | Description |
|---|---|
ListIdentityLinks |
List the current user's linked external identities. |
UnlinkIdentity |
Remove a linked external identity. |
| Method | Description |
|---|---|
ListAuditEvents |
Paginated event log. Filters: actor_id, stream_type, event_type. Returns raw event data from the event store. |
ExportAuditEvents |
Chunked CSV/JSON export of the filtered log (spec 26). Unary keyset-paginated (client loops next_page_token, concatenates chunks into one valid artifact); same read-side redaction and same ListAuditEvents permission as the list. Filters: actor_id, stream_types set, event_type substring, occurred_at range. |
Server-side full-text search across actions, action sets, and definitions. Backed by Valkey RediSearch (FT.CREATE/FT.SEARCH). The search index is managed by the Indexer service (cmd/indexer/), which reads from PostgreSQL and writes to Valkey. Incremental updates are processed via Asynq workers after every mutation. A periodic reconciliation rebuild runs every hour to correct any drift. Supports server-side date and tag filtering.
Search uses prefix matching — the query "ngi" matches "nginx", "engine", etc. Minimum query length is 2 characters (RediSearch default MINPREFIX 2). Single-character queries return no results.
When scope is empty, results are returned from all three indexes (actions, action sets, definitions). When set to a specific scope, only that index is queried.
| Method | Description | Permission |
|---|---|---|
Search |
Full-text search across actions, action sets, and definitions. Supports scoped queries and pagination. | Search |
RebuildSearchIndex |
Force a full rebuild of the search index from PostgreSQL. Admin-only. | RebuildSearchIndex |
| Method | Description |
|---|---|
GetDeviceCompliance |
Returns compliance check results and overall status for a device. |
CreateCompliancePolicy |
Create a named compliance policy. |
GetCompliancePolicy |
Get a compliance policy by ID, including its rules. |
ListCompliancePolicies |
Paginated list of compliance policies. |
RenameCompliancePolicy |
Rename a compliance policy. |
UpdateCompliancePolicyDescription |
Update a compliance policy's description. |
DeleteCompliancePolicy |
Delete a compliance policy (soft delete). |
AddCompliancePolicyRule |
Add a compliance script action as a rule with a grace period. |
RemoveCompliancePolicyRule |
Remove a rule from a compliance policy. |
UpdateCompliancePolicyRule |
Update the grace period of an existing rule. |
GetDeviceCompliancePolicyStatus |
Get per-policy, per-rule compliance evaluation status for a device, including grace period state. |
| Method | Description |
|---|---|
Register |
Agent registration. Validates token (hash, expiry, disabled, max uses), signs agent CSR to issue mTLS client certificate, generates device ID, auto-assigns to token owner. Returns device ID, CA cert, signed cert, gateway URL. |
RenewCertificate |
Certificate renewal. Agent presents its current (still valid) certificate and a new CSR. Server verifies the certificate was issued by the CA, checks the fingerprint matches the database, signs the new CSR, and emits a DeviceCertRenewed event. No JWT required. |
| Method | Description |
|---|---|
Stream |
Bidirectional streaming. Agent sends Hello (with arch), Heartbeat, ActionResult, OutputChunk, LogQueryResult, SecurityAlert. Server sends Welcome (with auto-update info), ActionDispatch, LogQuery. |
SyncActions |
Agent pulls all assigned actions for offline storage. Returns effective sync interval. |
Access tokens (15 min) and refresh tokens (7 days) with HMAC-SHA256 signing. Refresh token rotation — each refresh revokes the old token and issues a new pair. Claims include user ID, email, permissions, session version, and a unique JTI (ULID). Revoked tokens tracked in PostgreSQL with automatic cleanup.
Optional TOTP-based 2FA with HMAC-SHA1 (RFC 6238). Users enroll via SetupTOTP, verify with VerifyTOTP, and receive backup codes. TOTP verification is required at login when enabled. Backup codes are single-use and can be regenerated.
OIDC authorization code flow for external identity providers (Google, Okta, Azure AD, etc.). Features:
- Auto-create users on first SSO login (configurable per provider)
- Auto-link by email when existing user matches
- IdP group claim → user group mapping
- Optionally disable password authentication for SSO-linked users
- Multiple providers can be configured simultaneously
- Client-provided redirect URL support (enables non-browser OIDC flows, e.g. pm-enroll)
- Auto-delete orphaned users when an identity provider is deleted
REST API at /scim/v2/{slug}/ for automated user and group provisioning from external IdPs. Bearer token authentication (per-provider, bcrypt-hashed). Supports Users (CRUD, filter by userName/externalId) and Groups (CRUD with member management).
Automatic Linux user account management on devices. When provisioning is enabled (globally via server settings or per-user), the server creates managed ACTION_TYPE_USER and ACTION_TYPE_SSH actions that are assigned to the user's devices.
How it works:
SyncUserSystemActionscreates/updates/removes system actions based on provisioning settings- System actions are named
system:user-provision:{userID}andsystem:ssh-access:{userID} - The user action creates a Linux account with the configured
linux_usernameandlinux_uid - The SSH action configures SSH access (pubkey/password auth, authorized keys)
When sync runs:
- On server startup (once, for all users)
- After any user mutation (profile update, provisioning toggle, SSH key change, etc.)
- There is no periodic re-sync — if a manual DB change is made, toggling any user setting will trigger a re-sync
Requirements for provisioning to work:
- User must have a non-empty
linux_username(assigned during user creation) - Users created via SSO/OIDC or SCIM automatically get a
linux_usernameandlinux_uid - If
linux_usernameis empty,SyncUserSystemActionsskips the user with a warning
Permission-based authorization replaces the old admin/user role model. Roles are custom collections of permissions. Users can have multiple roles (directly assigned or inherited via user groups). Permissions include scoped variants like GetUser:self and ListDevices:assigned.
| Permission | Description |
|---|---|
StartTerminal |
Open a remote terminal session on a device |
StopTerminal |
Stop a remote terminal session you opened |
ListActiveTerminalSessions |
View active terminal sessions (admin) |
TerminateTerminalSession |
Forcibly terminate any terminal session (admin) |
Pure-Go authorizer evaluates every RPC call against the user's effective permissions (union of all assigned roles, including built-in roles synthesised from group membership). No OPA / Rego — the authorizer is a permission-map lookup with two scope variants:
:self— the resource owner is the authenticated user. Enforced at the handler boundary viaauth.EnforceSelfScopeso every handler that grants a:selfpermission rejects calls aimed at other users.:assigned— the resource is in the user's assignment scope (devices reachable through assignment, action sets the user owns, etc.).
The full permission catalog lives in internal/auth/permissions.go; the interceptor pipeline at internal/auth/interceptor.go wires it into Connect-RPC.
Sliding-window rate limiter keyed by IP address. Current per-procedure-family limits in cmd/control/main.go (audit F036 — closes #142 + #145):
| Limiter | Procedures gated | Limit | Rationale |
|---|---|---|---|
Login |
Login, VerifyLoginTOTP, SSOCallback |
10 / min / IP | credential-spray defense; all three RPCs share one budget — they're all auth-attempt vectors |
Refresh |
RefreshToken |
60 / min / IP | legitimate refreshes are frequent; budget large enough for normal use |
Register |
Register |
5 / min / IP | registration spam protection |
Logout |
Logout |
30 / min / IP | legitimate ceiling for multi-session logout (a real user might log out of several sessions sequentially) |
RenewCert |
RenewCertificate |
5 / min / IP | cert rotation happens at 80% of cert lifetime, not in a tight loop; CA signing is the limiting resource |
PublicProcedures lists Logout + RenewCertificate alongside the auth flows — both bypass the access-token check, so the rate-limiter is the only spray defense in front of them.
Background goroutine cleans up stale entries every 5 minutes.
JWTs travel exclusively in the Authorization: Bearer <token> header. Clients receive both access and refresh tokens in the Login / RefreshToken response bodies and store them themselves (typically in localStorage for the web client and in the credentials.enc store for the agent). The server issues no authentication cookies — there is no pm_access / pm_refresh cookie to steal via XSS and no SameSite negotiation to get wrong.
Internal CA signs agent CSRs during registration. Certificates use CN={deviceID}, valid for 1 year (configurable). The Gateway validates client certificates using RequireAndVerifyClientCert (TLS 1.3 minimum) and extracts device identity. Actions are also signed by the CA so agents can verify authenticity.
Peer-class enforcement. Every non-CA certificate carries a SPIFFE URI SAN of the form spiffe://power-manage/<class>, where <class> is one of agent, gateway, or control. The internal CA stamps agent on every cert it issues via CSR; setup.sh stamps gateway / control on the out-of-band certs for gateway replicas and the control server's internal listener. Middleware on each mTLS listener accepts only the expected class: the control server's InternalService admits gateway peers, the gateway's AgentService admits agent peers, and the gateway's GatewayService admits control peers. A leaked cert of one class therefore cannot be replayed against a listener intended for another class.
Certificate renewal is handled via the RenewCertificate RPC — agents present their current certificate and a new CSR. The server verifies the certificate was issued by a trusted CA (from the trust bundle if configured), checks the fingerprint matches the database record (preventing use of revoked certificates), signs the new CSR with the agent peer-class URI SAN, and returns the active CA certificate so agents can update their trust store during CA rotation.
RPCs with :self scoped permissions (e.g., GetUser:self, UpdateUserEmail:self) enforce that the resource ID matches the caller's user ID. The auth.EnforceUserScopeOrSelf() helper is called in each affected user handler after validation (it generalises the old EnforceSelfScope to also apply user-group scope — see below). Users with the unrestricted permission (e.g., GetUser) can access any resource, subject to group scope.
A role grant may carry a scope — device_group:<id> or user_group:<id> — confining every permission in that role to the devices/users in that group. Scope is enforced uniformly at the handler layer on every scopable permission (a permission is scopable iff it carries a TargetKind of TargetDevice / TargetUser in permissions.go). "Scopable == enforced" is kept honest: there is no advisory-scope allowlist, and TestScopablePermissions_AllEnforced (a self-discovering AST parity test in internal/api) fails the build if a scopable permission is ever left unenforced, or a non-scopable one is enforced.
Model: the flat permission set is the AUTHORITY (does the caller hold the base permission?), the scoped grants only CONFINE (which targets?). A base holder with no scoping grant is unrestricted; with a same-kind scoped grant, confined to it; with only a wrong-kind grant, denied (fail closed). Five mechanisms cover the surface:
- Single-resource gates —
EnforceDeviceScopeOnBaseTier/EnforceUserScopeOrSelffor handlers acting on one device/user id; the:assigned/:selftier falls through to the existing owner SQL filter. - Group-id direct-match gates —
EnforceDeviceGroupScope/EnforceUserGroupScopefor group-management handlers (the group id itself must be in the caller's scope). - List-row filters —
DeviceScopeListFilter/UserScopeListFilterconfine list rows (by membership for devices/users/executions, by direct id-match for group lists); the matching COUNT query takes the same restriction so pagination totals don't leak the out-of-scope count. - Dispatch fan-out —
enforceDeviceScopeAllchecks every target device and fails the whole request closed if any is out of scope. - Reconciler cohort —
TerminalAdminLimited/TerminalAdminFulldrive the per-scope pm-tty sudo cohort insystem_actions.gorather than a request-time gate.
Two safeguards: group creation (CreateStaticDeviceGroup / CreateStaticUserGroup) is org-tier (not scopable) — a brand-new group has no id/members to confine, so it is enforced on the downstream management/membership ops instead of being advisory. And member-add (AddDeviceToGroup / AddUserToGroup) checks both the target group and that each member is already in the caller's scope, so a scope-limited admin cannot pull an out-of-scope device/user into a group they control and thereby expand their own reach. See ADR 0006.
Assigning or defining roles is gated solely by holding the relevant role-management permission (AssignRoleToUser, AssignRoleToUserGroup, AddUserToGroup, CreateRole, UpdateRole, CreateUser). There is no grant-only-what-you-hold privilege ceiling: a holder of AssignRoleToUser may assign any role, including Admin, without personally holding every permission that role confers. These permissions are powerful by design and meant to be handed out deliberately. What remains enforced and orthogonal: scope authority (a scope-limited admin may only grant within their own scope — EnforceGrantScopeAuthority / EnforceUnscopedGrantAuthority), the atomic last-admin lockout invariant (server#369), and scope enforcement at call time (above).
The Control Server sets IdleTimeout (120s) and ReadHeaderTimeout (10s) on the HTTP server to prevent connection exhaustion attacks.
- Migrations:
internal/store/migrations/(Goose, embedded at compile time) - Queries:
internal/store/queries/*.sql(sqlc annotations) - Generated code:
internal/store/generated/(do not edit — runsqlc generateininternal/store/) - Config:
internal/store/sqlc.yaml(pgx/v5 driver)
# All binaries
CGO_ENABLED=0 go build -ldflags="-s -w" -o control ./cmd/control
CGO_ENABLED=0 go build -ldflags="-s -w" -o gateway ./cmd/gateway
CGO_ENABLED=0 go build -ldflags="-s -w" -o indexer ./cmd/indexer
# With version injection
CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=2026.3.0" -o control ./cmd/control
CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=2026.3.0" -o gateway ./cmd/gateway| Variable | Description |
|---|---|
GATEWAY_VALKEY_ADDR |
Valkey address, e.g. localhost:6379 |
GATEWAY_VALKEY_PASSWORD |
Valkey password |
GATEWAY_VALKEY_DB |
Valkey DB number (default 0) |
GATEWAY_CONTROL_URL |
URL of the Control Server |
GATEWAY_ID |
Stable gateway identifier (empty = generate ULID at startup) |
GATEWAY_PUBLIC_TERMINAL_URL_TEMPLATE |
Template for the public terminal WebSocket URL, e.g. wss://{id}.gateway.example.com/terminal |
GATEWAY_BOOTSTRAP_HOST |
Wildcard root hostname for agent bootstrap redirect, e.g. gateway.example.com |
GATEWAY_WEB_LISTEN_ADDR |
Listen address for the terminal-WebSocket HTTP listener (cleartext; Traefik terminates TLS upstream), e.g. :8443 |
GATEWAY_LOG_LEVEL |
Log level: debug, info, warn, error (default info) |
GATEWAY_HEARTBEAT_INTERVAL |
Heartbeat cadence sent to agents (Go duration, 5s..5m; default 30s) |
GATEWAY_TRAEFIK_TTY_CERT_RESOLVER |
Traefik cert resolver name for the per-replica TTY HTTP router (e.g. letsencrypt) |
CONTROL_TERMINAL_GATEWAY_URL |
Fallback terminal gateway URL for single-gateway deployments (deprecated in favor of registry) |
rc3 migration: the previously unprefixed
VALKEY_ADDR/VALKEY_PASSWORD/VALKEY_DB/LOG_LEVELare nowGATEWAY_*-prefixed. The old names are no longer read — rename them in your.envbefore upgrading.
| Variable | Description |
|---|---|
INDEXER_DATABASE_URL |
PostgreSQL DSN (read-only role recommended) |
INDEXER_VALKEY_ADDR |
Valkey address, e.g. localhost:6379 |
INDEXER_VALKEY_PASSWORD |
Valkey password |
INDEXER_VALKEY_DB |
Valkey DB number (default 0) |
INDEXER_LOG_LEVEL |
Log level: debug, info, warn, error (default info) |
INDEXER_LOG_FORMAT |
Log format: text or json (default text) |
INDEXER_RECONCILE_INTERVAL |
Periodic reconciliation cadence (Go duration, default 1h) |
INDEXER_CONCURRENCY |
Asynq worker concurrency (default 5) |
INDEXER_HEALTH_ADDR |
HTTP listen address for /health (default :8090) |
Requires a running PostgreSQL and Valkey instance. See the self-hosting guide for Docker/Podman Compose deployment.
# Control server
./control \
-addr=:8081 \
-db="postgres://user:pass@localhost:5432/powermanage?sslmode=disable" \
-jwt-secret="$(openssl rand -base64 48)" \
-ca-cert=certs/ca.crt \
-ca-key=certs/ca.key \
-gateway-url=https://localhost:8080
# Gateway server (no database required, connects to Valkey and Control)
export GATEWAY_VALKEY_ADDR=localhost:6379
export GATEWAY_VALKEY_PASSWORD=your-valkey-password
export GATEWAY_CONTROL_URL=https://localhost:8082
./gateway -tls-cert=certs/gateway.crt -tls-key=certs/gateway.key -tls-ca=certs/ca.crt# After editing SQL queries
cd internal/store && sqlc generate
# After editing proto definitions (in the SDK repo)
cd ../../sdk && make generateThe server pins a specific SDK tag via go.mod's replace directive:
replace github.com/manchtools/power-manage/sdk => github.com/manchtools/power-manage-sdk v0.1.0
The replace maps the monorepo-style import path (github.com/manchtools/power-manage/sdk) to the actual polyrepo URL (github.com/manchtools/power-manage-sdk). go build fetches the exact tagged version from GitHub — SDK main can move freely without breaking server builds. When the server is ready to consume a newer SDK:
cd server
go get github.com/manchtools/power-manage-sdk@v0.2.0 # or any tag / commit SHA
go mod tidyThe SDK is still pre-v1.0.0, so minor bumps (v0.1.0 → v0.2.0) may carry breaking API changes. Expect each bump PR to carry the matching migration in the same commit.
For cross-cutting changes, use a go.work at the workspace root (the directory that contains both sdk/ and server/ checkouts). It overrides any replace directive.
# At the workspace root (NOT committed — each dev manages their own):
cat > go.work <<'EOF'
go 1.25
use (
./sdk
./server
)
EOFRename it to go.work.off or delete it when you want go build to use the pinned SDK again.
The server test surface lives across internal/api/, internal/auth/, internal/projectors/, internal/store/, internal/control/, internal/handler/, internal/scim/, internal/middleware/, internal/eventtypes/ and the rest of internal/. Run find . -name '*_test.go' | wc -l for an up-to-date count; numeric claims drift quickly so the canonical reference is the file system.
# Unit tests only (no Docker/Podman required, ~8s)
go test ./internal/auth/... ./internal/connection/...
# Integration tests (requires Docker or Podman, ~15 min total)
go test -timeout 20m ./internal/store/... ./internal/api/... ./internal/handler/...
# Full suite
go test -timeout 20m ./...Podman users: testcontainers-go expects a Docker socket. Set these environment variables:
export DOCKER_HOST=unix:///run/user/1000/podman/podman.sock
export TESTCONTAINERS_RYUK_DISABLED=trueIntegration tests use testcontainers-go to spin up a real PostgreSQL 17 container per test. This validates the full event-sourcing pipeline — events are appended, PostgreSQL triggers fire, projections are populated, and queries return the correct data. Each test gets an isolated container that is automatically torn down via t.Cleanup().
Tests create entities directly via store.AppendEvent() rather than through handlers, ensuring each test is isolated to the layer it's testing. Auth context is injected via auth.WithUser(ctx, ...) to bypass interceptors (which are tested separately).
Test isolation relies on unique ULIDs — every test entity gets a unique ID, so tests sharing a database never interfere with each other.
The testutil package provides shared helpers used across all integration tests:
| Helper | Description |
|---|---|
SetupPostgres(t) |
Starts a PostgreSQL testcontainer, runs Goose migrations, returns a connected *store.Store. |
NewID() |
Generates a unique ULID for test isolation. |
CreateTestUser(t, st, email, password, role) |
Creates a user via UserCreated event. Uses a precomputed bcrypt hash for the default password "pass" to avoid ~1-2s bcrypt cost per test. |
CreateTestDevice(t, st, hostname) |
Creates a device via DeviceRegistered event. |
CreateTestAction(t, st, actorID, name, actionType) |
Creates an action via ActionCreated event. |
CreateTestActionSet(t, st, actorID, name) |
Creates an action set via ActionSetCreated event. |
CreateTestDefinition(t, st, actorID, name) |
Creates a definition via DefinitionCreated event. |
CreateTestDeviceGroup(t, st, actorID, name) |
Creates a device group via DeviceGroupCreated event. |
CreateTestToken(t, st, actorID, name, hash) |
Creates a registration token via TokenCreated event. |
CreateTestRole(t, st, actorID, name, perms) |
Creates a role via RoleCreated event. |
CreateTestUserGroup(t, st, actorID, name) |
Creates a user group via UserGroupCreated event. |
CreateTestIdentityProvider(t, st, enc, actorID, name, slug) |
Creates an identity provider via IdentityProviderCreated event. |
CreateTestIdentityLink(t, st, actorID, userID, providerID, externalID) |
Creates an identity link via IdentityLinked event. |
EnableSCIMForProvider(t, st, actorID, providerID) |
Enables SCIM for a provider via IdentityProviderSCIMEnabled event. Returns the plaintext token. |
NewEncryptor(t) |
Creates a test AES-GCM encryptor. |
AdminContext(id) |
Returns a context.Context with an admin user injected (all permissions). |
UserContext(id) |
Returns a context.Context with a regular user injected (default user permissions). |
DisableEvent(userID) |
Returns a UserDisabled event for use with AppendEvent. |
NewJWTManager() |
Creates a JWTManager with test-friendly configuration (15 min access, 1 hr refresh). |
The per-file Tests counts below are approximate snapshots — they drift as tests are added or removed. For the current number, run go test -count=1 -v ./... | grep -c '^=== RUN'.
No database required. Test pure Go logic.
| File | Tests | What it covers |
|---|---|---|
internal/auth/jwt_test.go |
14 | Token generation, validation (valid/expired/wrong-type/wrong-secret), refresh with rotation, revocation, unique JTIs |
internal/auth/password_test.go |
6 | bcrypt hashing, verification (correct/wrong/empty), unique salts, dummy hash for timing attack prevention |
internal/auth/ratelimit_test.go |
5 | Allow within limit, block after limit, independent keys, window expiry, 200-goroutine concurrent access |
internal/auth/context_test.go |
9 | User/device context storage and retrieval, SubjectFromContext precedence |
internal/auth/opa_test.go |
17 | Admin allows all 18 actions, user self-access vs. other-access, user denied admin actions, device own-resource vs. other |
internal/auth/interceptor_test.go |
8 | Public procedure list (Login, RefreshToken, Logout, Register), non-public procedures, interceptor creation, streaming passthrough |
| File | Tests | What it covers |
|---|---|---|
internal/connection/manager_test.go |
14 | Register/Get, replace existing connection, unregister, count, list, IsConnected, UpdateLastSeen, Send to disconnected agent, context cancellation, 100-goroutine concurrent access |
| File | Tests | What it covers |
|---|---|---|
internal/store/store_test.go |
~31 | AppendEvent basics, auto-versioning (5 events), explicit version conflict, WithTx commit/rollback, Notify. Projection tests: UserCreated/EmailChanged/Disabled/Enabled/Deleted, DeviceRegistered/Heartbeat/LabelSet, ActionCreated, ActionSetWithMembers, DefinitionCreated, DeviceGroupCreated, TokenCreated, ExecutionLifecycle (created → dispatched → completed), AssignmentCreated |
Each test spins up a PostgreSQL container and tests handler methods directly.
| File | Tests | What it covers |
|---|---|---|
internal/api/validator_test.go |
9 | Struct validation: required fields, email, ULID, min-length, optional fields, snake_case conversion |
internal/api/auth_handler_test.go |
15 | Login (success, wrong password, nonexistent user, disabled user, token pair in response body, TOTP verification), GetCurrentUser, SSO callback |
internal/api/user_handler_test.go |
11 | CreateUser, GetUser (found, not found), ListUsers pagination, UpdateEmail, UpdatePassword (self, wrong current, privileged), SetUserDisabled, DeleteUser |
internal/api/device_handler_test.go |
10 | ListDevices (empty, with devices), GetDevice (found, not found), SetDeviceLabel, RemoveDeviceLabel, DeleteDevice, AssignDevice, UnassignDevice, SetDeviceSyncInterval |
internal/api/token_handler_test.go |
7 | CreateToken (admin, user one-time), GetToken (value hidden), ListTokens, RenameToken, SetTokenDisabled, DeleteToken |
internal/api/action_handler_test.go |
12 | CreateAction (shell, default timeout), GetAction (found, not found), ListActions, RenameAction, DeleteAction, DispatchAction (by ID, device not found), ListExecutions, GetExecution, DispatchInstantAction |
internal/api/action_set_handler_test.go |
8 | CreateActionSet, GetActionSet, ListActionSets, RenameActionSet, DeleteActionSet, AddActionToSet, RemoveActionFromSet, ReorderActionInSet |
internal/api/definition_handler_test.go |
6 | CreateDefinition, GetDefinition, ListDefinitions, RenameDefinition, DeleteDefinition, AddActionSetToDefinition |
internal/api/device_group_handler_test.go |
10 | CreateDeviceGroup (static), GetDeviceGroup (found, not found), ListDeviceGroups, RenameDeviceGroup, DeleteDeviceGroup, AddDeviceToGroup, RemoveDeviceFromGroup, SetDeviceGroupSyncInterval, ValidateDynamicQuery |
internal/api/assignment_handler_test.go |
10 | CreateAssignment (action→device, set→group, user target, user_group target, idempotent), DeleteAssignment, ListAssignments, GetDeviceAssignments, GetUserAssignments |
internal/api/audit_handler_test.go |
4 | ListAuditEvents, filter by stream_type, filter by event_type, pagination |
internal/api/audit_export_test.go |
9 | ExportAuditEvents: CSV/JSON artifacts, chunk concatenation without duplicates, redaction via the real CreateAction emit, actor/date/event-type filters (incl. ILIKE metachar regression), unsupported format, invalid page token, stream_types slice cap |
internal/api/totp_handler_test.go |
14 | SetupTOTP, VerifyTOTP (valid/invalid/replay), DisableTOTP, GetTOTPStatus, RegenerateBackupCodes, backup code login |
internal/api/user_group_handler_test.go |
20 | CreateUserGroup, GetUserGroup, ListUserGroups, UpdateUserGroup, DeleteUserGroup, AddUserToGroup, RemoveUserFromGroup, AssignRoleToUserGroup, RevokeRoleFromUserGroup, ListUserGroupsForUser, additive permissions |
internal/api/idp_handler_test.go |
15 | CreateIdentityProvider (success, duplicate slug, group mapping), Get/List/Update/Delete IDP, EnableSCIM (success, already enabled), DisableSCIM (success, not enabled), RotateSCIMToken (success, not enabled) |
internal/api/sso_handler_test.go |
6 | SSO authorization URL generation, callback handling, identity linking |
internal/api/identity_link_handler_test.go |
6 | ListIdentityLinks, UnlinkIdentity |
internal/api/luks_action_test.go |
7 | LUKS key management actions |
internal/api/osquery_handler_test.go |
11 | OSQuery dispatch, result retrieval, device inventory |
| File | Tests | What it covers |
|---|---|---|
internal/scim/handler_test.go |
21 | Auth (missing/invalid/non-existent/valid token), Discovery (ServiceProviderConfig, Schemas, ResourceTypes), Users (create, get, list, filter, replace, patch deactivate, delete), Groups (create, get, list, patch add member, replace members, delete) |
| File | Tests | What it covers |
|---|---|---|
internal/handler/agent_test.go |
~3 | SyncActions (missing device ID), DeviceIDFromContext (present, absent) |
Dynamic device groups use a query language to automatically evaluate device membership based on device properties. Membership is re-evaluated whenever a device's inventory or labels change.
Label properties — custom key-value labels assigned to devices:
device.labels.<key> e.g., device.labels.environment
labels.<key> e.g., labels.role (shorthand)
Device properties — available immediately from registration:
| Property | Description | Example Value |
|---|---|---|
device.hostname |
Device hostname | web-server-01 |
Inventory properties — hardware and OS information collected by the agent via OSQuery. These fields are only available after the agent has connected and sent its first inventory report (typically within seconds of first connection). Queries using these fields will not match devices that have not yet reported inventory.
| Property | Description | Example Value |
|---|---|---|
device.os |
Operating system name | Ubuntu, Fedora |
device.os_version |
Full OS version string | 24.04, 41 |
device.os_major |
OS major version number | 24, 41 |
device.os_minor |
OS minor version number | 04, 0 |
device.os_arch |
CPU architecture | x86_64, aarch64 |
device.os_platform |
Platform family | debian, rhel, arch |
device.cpu_type |
CPU type | GenuineIntel |
device.cpu_brand |
CPU brand string | Intel(R) Core(TM) i7-1265U |
device.cpu_cores |
Physical CPU core count | 10 |
device.cpu_logical_cores |
Logical CPU core count | 12 |
device.memory_total |
Total memory in bytes | 17179869184 |
device.kernel |
Kernel version | 6.8.0-45-generic |
| Operator | Description | Example |
|---|---|---|
equals |
Exact match | device.os equals "Ubuntu" |
notEquals |
Not equal | device.os notEquals "Windows" |
contains |
Substring match | device.hostname contains "web" |
notContains |
No substring match | device.hostname notContains "test" |
startsWith |
Prefix match | device.hostname startsWith "prod-" |
endsWith |
Suffix match | device.hostname endsWith ".local" |
greaterThan |
Numeric greater than | device.cpu_cores greaterThan "4" |
lessThan |
Numeric less than | device.memory_total lessThan "8589934592" |
exists |
Property exists | device.labels.environment exists |
notExists |
Property does not exist | device.labels.decommissioned notExists |
in |
Value in list | device.os in ("Ubuntu", "Fedora") |
notIn |
Value not in list | device.os_platform notIn ("arch", "gentoo") |
matches |
Regex match | device.hostname matches "^web-[0-9]+" |
notMatches |
No regex match | device.kernel notMatches "debug" |
Conditions can be combined with logic operators and grouped with parentheses:
AND— both conditions must be trueOR— at least one condition must be trueNOT— negates a condition- Parentheses — control evaluation order
# All Ubuntu devices
device.os equals "Ubuntu"
# 64-bit devices with at least 8 GB RAM
device.os_arch equals "x86_64" AND device.memory_total greaterThan "8589934592"
# Production devices on Debian or RHEL-based systems
device.labels.environment equals "production" AND device.os_platform in ("debian", "rhel")
# Fedora or Ubuntu devices with more than 4 CPU cores
(device.os equals "Fedora" OR device.os equals "Ubuntu") AND device.cpu_cores greaterThan "4"
# Devices without a decommissioned label
NOT device.labels.decommissioned exists
Dynamic group membership is evaluated by PostgreSQL using the evaluate_dynamic_query_v2() function. The query is parsed into an expression tree of conditions, logical operators, and groups. Each condition is evaluated against the device's labels (stored in devices_projection.labels) and inventory data (collected by the agent via OSQuery and stored in device_inventory).
Data availability by source:
| Source | Available | Fields |
|---|---|---|
| Registration | Immediately | device.hostname, device.labels.* |
| Agent inventory (OSQuery) | After first connection | device.os, device.os_version, device.os_arch, device.cpu_*, device.memory_total, device.kernel |
The ValidateDynamicQuery RPC validates syntax and returns the number of currently matching devices. The EvaluateDynamicGroup RPC triggers a manual re-evaluation of membership.
AGPL-3.0 — see LICENSE.