Implement user-bound join flows and add admin image build pipeline
Some checks failed
build-local-image / build (push) Has been cancelled

This commit is contained in:
centra 2026-02-14 15:46:25 +09:00
parent 6ae8b6e898
commit 98eb7057a5
Signed by: centra
GPG key ID: 0C09689D20B25ACA
16 changed files with 1000 additions and 113 deletions

8
.dockerignore Normal file
View file

@ -0,0 +1,8 @@
.git
.gitignore
target
frontend/node_modules
frontend/dist
backend/target
Dockerfile*
.forgejo

View file

@ -0,0 +1,20 @@
name: build-local-image
on:
push:
branches:
- main
workflow_dispatch:
jobs:
build:
runs-on: nix-host
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build local image on runner host
run: docker build --pull -t lightscale-admin:local .
- name: Show built image
run: docker image ls lightscale-admin:local

34
Dockerfile Normal file
View file

@ -0,0 +1,34 @@
# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim AS frontend-build
WORKDIR /src/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM rust:1.88-bookworm AS backend-build
WORKDIR /src
COPY Cargo.toml Cargo.lock ./
COPY backend ./backend
RUN cargo build --release -p lightscale-admin-server
FROM debian:bookworm-slim AS runtime
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --system --create-home --uid 10001 lightscale
WORKDIR /app
COPY --from=backend-build /src/target/release/lightscale-admin-server /usr/local/bin/lightscale-admin-server
COPY --from=frontend-build /src/frontend/dist /app/frontend/dist
USER lightscale
EXPOSE 8081
ENV RUST_LOG=info
ENV LS_ADMIN__SERVER__BIND=0.0.0.0:8081
ENV LS_ADMIN__SERVER__STATIC_DIR=/app/frontend/dist
CMD ["/usr/local/bin/lightscale-admin-server"]

View file

@ -6,6 +6,37 @@ A thin admin control plane for Lightscale. It stores operator metadata in Cockro
- `backend/`: Rust (Axum) API server, `/admin/api` namespace. - `backend/`: Rust (Axum) API server, `/admin/api` namespace.
- `frontend/`: Vite React SPA. - `frontend/`: Vite React SPA.
## Features
### Authentication
- Local account authentication
- OIDC authentication
- Session management
- Bootstrap admin creation
### RBAC
- Role-based access control
- 5 default roles (Owner, Admin, Viewer, Member, Joiner)
- Membership management
- `console:access` permission gate for admin console/API access
- `join_tokens:create` permission for non-console self-service device enrollment
### Control Plane Management
- CRUD operations (Create, Read, Update, Delete)
- Health check/verification
### Network Management
- Create/Delete/List networks
- Node management (approve, revoke, key rotation)
- Token management (create, revoke)
- Self-service join token APIs (`/admin/api/auth/join-networks`, `/admin/api/auth/join-token`)
- ACL configuration
- Key policy configuration
### Audit
- Admin audit log
- Control Plane audit log
## Quick start ## Quick start
1) Start CockroachDB (single node for local dev): 1) Start CockroachDB (single node for local dev):
@ -45,6 +76,7 @@ Key settings:
- `auth.bootstrap_admin_email` / `auth.bootstrap_admin_password`: creates the first admin if the database is empty. - `auth.bootstrap_admin_email` / `auth.bootstrap_admin_password`: creates the first admin if the database is empty.
- `server.allowed_origins`: set when the UI is hosted separately (CORS + cookies). - `server.allowed_origins`: set when the UI is hosted separately (CORS + cookies).
- `server.static_dir`: serve the SPA from this folder (usually `../frontend/dist`). - `server.static_dir`: serve the SPA from this folder (usually `../frontend/dist`).
- `database.disable_migration_locking`: optional override to disable SQLx migration advisory locks (`LS_ADMIN__DATABASE__DISABLE_MIGRATION_LOCKING=true`). CockroachDB is auto-detected.
## Control planes ## Control planes
Create control planes in the UI and store their admin tokens. The admin API will call each control planes `/v1/*` endpoints to manage networks and nodes. Create control planes in the UI and store their admin tokens. The admin API will call each control planes `/v1/*` endpoints to manage networks and nodes.

View file

@ -113,7 +113,9 @@ INSERT INTO roles (id, name, description, created_at)
VALUES VALUES
('00000000-0000-0000-0000-000000000001', 'owner', 'Full access', now()), ('00000000-0000-0000-0000-000000000001', 'owner', 'Full access', now()),
('00000000-0000-0000-0000-000000000002', 'admin', 'Network admin access', now()), ('00000000-0000-0000-0000-000000000002', 'admin', 'Network admin access', now()),
('00000000-0000-0000-0000-000000000003', 'viewer', 'Read-only access', now()); ('00000000-0000-0000-0000-000000000003', 'viewer', 'Read-only access', now()),
('00000000-0000-0000-0000-000000000004', 'member', 'Authenticated user without console access', now()),
('00000000-0000-0000-0000-000000000005', 'joiner', 'Can mint user-bound join tokens (typically network-scoped)', now());
INSERT INTO role_permissions (role_id, permission, created_at) INSERT INTO role_permissions (role_id, permission, created_at)
VALUES VALUES
@ -124,6 +126,7 @@ VALUES
('00000000-0000-0000-0000-000000000001', 'nodes:read', now()), ('00000000-0000-0000-0000-000000000001', 'nodes:read', now()),
('00000000-0000-0000-0000-000000000001', 'nodes:write', now()), ('00000000-0000-0000-0000-000000000001', 'nodes:write', now()),
('00000000-0000-0000-0000-000000000001', 'tokens:write', now()), ('00000000-0000-0000-0000-000000000001', 'tokens:write', now()),
('00000000-0000-0000-0000-000000000001', 'join_tokens:create', now()),
('00000000-0000-0000-0000-000000000001', 'acl:read', now()), ('00000000-0000-0000-0000-000000000001', 'acl:read', now()),
('00000000-0000-0000-0000-000000000001', 'acl:write', now()), ('00000000-0000-0000-0000-000000000001', 'acl:write', now()),
('00000000-0000-0000-0000-000000000001', 'key_policy:read', now()), ('00000000-0000-0000-0000-000000000001', 'key_policy:read', now()),
@ -133,6 +136,7 @@ VALUES
('00000000-0000-0000-0000-000000000001', 'users:write', now()), ('00000000-0000-0000-0000-000000000001', 'users:write', now()),
('00000000-0000-0000-0000-000000000001', 'roles:read', now()), ('00000000-0000-0000-0000-000000000001', 'roles:read', now()),
('00000000-0000-0000-0000-000000000001', 'roles:write', now()), ('00000000-0000-0000-0000-000000000001', 'roles:write', now()),
('00000000-0000-0000-0000-000000000001', 'console:access', now()),
('00000000-0000-0000-0000-000000000002', 'control_planes:read', now()), ('00000000-0000-0000-0000-000000000002', 'control_planes:read', now()),
('00000000-0000-0000-0000-000000000002', 'networks:read', now()), ('00000000-0000-0000-0000-000000000002', 'networks:read', now()),
@ -140,6 +144,7 @@ VALUES
('00000000-0000-0000-0000-000000000002', 'nodes:read', now()), ('00000000-0000-0000-0000-000000000002', 'nodes:read', now()),
('00000000-0000-0000-0000-000000000002', 'nodes:write', now()), ('00000000-0000-0000-0000-000000000002', 'nodes:write', now()),
('00000000-0000-0000-0000-000000000002', 'tokens:write', now()), ('00000000-0000-0000-0000-000000000002', 'tokens:write', now()),
('00000000-0000-0000-0000-000000000002', 'join_tokens:create', now()),
('00000000-0000-0000-0000-000000000002', 'acl:read', now()), ('00000000-0000-0000-0000-000000000002', 'acl:read', now()),
('00000000-0000-0000-0000-000000000002', 'acl:write', now()), ('00000000-0000-0000-0000-000000000002', 'acl:write', now()),
('00000000-0000-0000-0000-000000000002', 'key_policy:read', now()), ('00000000-0000-0000-0000-000000000002', 'key_policy:read', now()),
@ -147,9 +152,13 @@ VALUES
('00000000-0000-0000-0000-000000000002', 'audit:read', now()), ('00000000-0000-0000-0000-000000000002', 'audit:read', now()),
('00000000-0000-0000-0000-000000000002', 'users:read', now()), ('00000000-0000-0000-0000-000000000002', 'users:read', now()),
('00000000-0000-0000-0000-000000000002', 'roles:read', now()), ('00000000-0000-0000-0000-000000000002', 'roles:read', now()),
('00000000-0000-0000-0000-000000000002', 'console:access', now()),
('00000000-0000-0000-0000-000000000003', 'control_planes:read', now()), ('00000000-0000-0000-0000-000000000003', 'control_planes:read', now()),
('00000000-0000-0000-0000-000000000003', 'networks:read', now()), ('00000000-0000-0000-0000-000000000003', 'networks:read', now()),
('00000000-0000-0000-0000-000000000003', 'nodes:read', now()), ('00000000-0000-0000-0000-000000000003', 'nodes:read', now()),
('00000000-0000-0000-0000-000000000003', 'audit:read', now()), ('00000000-0000-0000-0000-000000000003', 'audit:read', now()),
('00000000-0000-0000-0000-000000000003', 'roles:read', now()); ('00000000-0000-0000-0000-000000000003', 'roles:read', now()),
('00000000-0000-0000-0000-000000000003', 'console:access', now()),
('00000000-0000-0000-0000-000000000005', 'join_tokens:create', now());

View file

@ -1,8 +1,16 @@
use crate::api::{ApiError, AuthUser}; use crate::api::ApiError;
use crate::app_state::AppState; use crate::app_state::AppState;
use crate::auth::{create_session, delete_session, hash_password, verify_password, SESSION_COOKIE}; use crate::audit_log;
use crate::auth::{
create_session, delete_session, hash_password, session_user, verify_password, SESSION_COOKIE,
};
use crate::control_plane::{CreateTokenRequest as CpCreateTokenRequest, EnrollmentToken};
use crate::models::User; use crate::models::User;
use crate::oidc::{build_auth_request, exchange_code}; use crate::oidc::{build_auth_request, exchange_code};
use crate::permissions::{
ensure_permission, list_permissions_global, PERM_CONSOLE_ACCESS, PERM_JOIN_TOKENS_CREATE,
PERM_TOKENS_WRITE,
};
use axum::extract::{Path, Query, State}; use axum::extract::{Path, Query, State};
use axum::response::Redirect; use axum::response::Redirect;
use axum::routing::{get, post}; use axum::routing::{get, post};
@ -16,6 +24,12 @@ use sqlx::{FromRow, PgPool};
use time::{Duration, OffsetDateTime}; use time::{Duration, OffsetDateTime};
use uuid::Uuid; use uuid::Uuid;
const JOIN_SCOPE_TYPE_NETWORK: &str = "network";
const DEFAULT_JOIN_TOKEN_TTL_SECONDS: u64 = 600;
const MAX_JOIN_TOKEN_TTL_SECONDS: u64 = 86_400;
const DEFAULT_JOIN_TOKEN_USES: u32 = 1;
const MAX_JOIN_TOKEN_USES: u32 = 5;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct LoginRequest { struct LoginRequest {
email: String, email: String,
@ -25,6 +39,8 @@ struct LoginRequest {
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct LoginResponse { struct LoginResponse {
user: UserSummary, user: UserSummary,
permissions: Vec<String>,
console_access: bool,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@ -61,11 +77,55 @@ struct OidcStateRow {
expires_at: OffsetDateTime, expires_at: OffsetDateTime,
} }
#[derive(Debug, Deserialize)]
struct CreateJoinTokenRequest {
network_id: Uuid,
ttl_seconds: Option<u64>,
uses: Option<u32>,
tags: Option<Vec<String>>,
}
#[derive(Debug, Serialize)]
struct CreateJoinTokenResponse {
network: JoinableNetwork,
token: EnrollmentToken,
}
#[derive(Debug, Serialize)]
struct JoinableNetwork {
id: Uuid,
control_plane_id: Uuid,
control_plane_name: String,
network_id: String,
name: String,
dns_domain: Option<String>,
overlay_v4: Option<String>,
overlay_v6: Option<String>,
requires_approval: bool,
}
#[derive(Debug, FromRow)]
struct JoinableNetworkRow {
id: Uuid,
control_plane_id: Uuid,
control_plane_name: String,
network_id: String,
name: String,
dns_domain: Option<String>,
overlay_v4: Option<String>,
overlay_v6: Option<String>,
requires_approval: bool,
base_url: String,
admin_token: Option<String>,
}
pub fn router() -> Router<AppState> { pub fn router() -> Router<AppState> {
Router::new() Router::new()
.route("/login", post(login)) .route("/login", post(login))
.route("/logout", post(logout)) .route("/logout", post(logout))
.route("/me", get(me)) .route("/me", get(me))
.route("/join-networks", get(join_networks))
.route("/join-token", post(create_join_token))
.route("/providers", get(list_providers)) .route("/providers", get(list_providers))
.route("/oidc/:provider/login", get(oidc_login)) .route("/oidc/:provider/login", get(oidc_login))
.route("/oidc/:provider/callback", get(oidc_callback)) .route("/oidc/:provider/callback", get(oidc_callback))
@ -109,17 +169,8 @@ async fn login(
let cookie = build_cookie(&state, token); let cookie = build_cookie(&state, token);
let jar = jar.add(cookie); let jar = jar.add(cookie);
Ok(( let response = auth_response(&state, user).await?;
jar, Ok((jar, Json(response)))
Json(LoginResponse {
user: UserSummary {
id: user.id,
email: user.email,
display_name: user.display_name,
super_admin: user.super_admin,
},
}),
))
} }
async fn logout( async fn logout(
@ -136,12 +187,79 @@ async fn logout(
Ok((jar, Json(json!({ "ok": true })))) Ok((jar, Json(json!({ "ok": true }))))
} }
async fn me(AuthUser { user }: AuthUser) -> Result<Json<UserSummary>, ApiError> { async fn me(
Ok(Json(UserSummary { State(state): State<AppState>,
id: user.id, jar: CookieJar,
email: user.email, ) -> Result<Json<LoginResponse>, ApiError> {
display_name: user.display_name, let user = authenticated_user(&state, &jar).await?;
super_admin: user.super_admin, let response = auth_response(&state, user).await?;
Ok(Json(response))
}
async fn join_networks(
State(state): State<AppState>,
jar: CookieJar,
) -> Result<Json<Vec<JoinableNetwork>>, ApiError> {
let user = authenticated_user(&state, &jar).await?;
let networks = fetch_joinable_networks(&state.pool, &user).await?;
Ok(Json(
networks
.into_iter()
.map(JoinableNetwork::from_row)
.collect(),
))
}
async fn create_join_token(
State(state): State<AppState>,
jar: CookieJar,
Json(req): Json<CreateJoinTokenRequest>,
) -> Result<Json<CreateJoinTokenResponse>, ApiError> {
let user = authenticated_user(&state, &jar).await?;
let network = fetch_network_for_join(&state.pool, req.network_id).await?;
ensure_join_token_permission(&state.pool, &user, network.id).await?;
let ttl_seconds = normalize_join_token_ttl(req.ttl_seconds)?;
let uses = normalize_join_token_uses(req.uses)?;
let tags = req.tags.unwrap_or_default();
let client = crate::control_plane::ControlPlaneClient::new(
network.base_url.clone(),
network.admin_token.clone(),
);
let response = client
.create_token(
&network.network_id,
CpCreateTokenRequest {
ttl_seconds,
uses,
tags,
owner_user_id: Some(user.id.to_string()),
owner_email: Some(user.email.clone()),
owner_is_admin: Some(false),
},
)
.await
.map_err(|_| ApiError::BadRequest("control plane request failed".to_string()))?;
audit_log::record(
&state.pool,
Some(user.id),
"token.create_self_service",
Some("network"),
Some(&network.id.to_string()),
Some(serde_json::json!({
"network_id": network.network_id,
"owner_user_id": user.id,
"owner_email": user.email,
"owner_is_admin": false,
})),
)
.await?;
Ok(Json(CreateJoinTokenResponse {
network: JoinableNetwork::from_row(network),
token: response.token,
})) }))
} }
@ -327,7 +445,7 @@ async fn ensure_oidc_user(
.map_err(|_| ApiError::Internal)?; .map_err(|_| ApiError::Internal)?;
let role_id = sqlx::query_scalar::<_, Uuid>( let role_id = sqlx::query_scalar::<_, Uuid>(
"SELECT id FROM roles WHERE name = 'viewer'", "SELECT id FROM roles WHERE name = 'member'",
) )
.fetch_one(&mut *tx) .fetch_one(&mut *tx)
.await .await
@ -363,6 +481,24 @@ async fn ensure_oidc_user(
Ok(user) Ok(user)
} }
async fn auth_response(state: &AppState, user: User) -> Result<LoginResponse, ApiError> {
let permissions = list_permissions_global(&state.pool, &user).await?;
let console_access = user.super_admin
|| permissions
.iter()
.any(|permission| permission == PERM_CONSOLE_ACCESS);
Ok(LoginResponse {
user: UserSummary {
id: user.id,
email: user.email,
display_name: user.display_name,
super_admin: user.super_admin,
},
permissions,
console_access,
})
}
fn build_cookie(state: &AppState, token: String) -> Cookie<'static> { fn build_cookie(state: &AppState, token: String) -> Cookie<'static> {
let mut cookie = Cookie::build((SESSION_COOKIE, token)) let mut cookie = Cookie::build((SESSION_COOKIE, token))
.http_only(true) .http_only(true)
@ -444,3 +580,165 @@ pub async fn ensure_bootstrap_admin(pool: &PgPool, config: &crate::config::Confi
Ok(()) Ok(())
} }
impl JoinableNetwork {
fn from_row(row: JoinableNetworkRow) -> Self {
Self {
id: row.id,
control_plane_id: row.control_plane_id,
control_plane_name: row.control_plane_name,
network_id: row.network_id,
name: row.name,
dns_domain: row.dns_domain,
overlay_v4: row.overlay_v4,
overlay_v6: row.overlay_v6,
requires_approval: row.requires_approval,
}
}
}
async fn authenticated_user(state: &AppState, jar: &CookieJar) -> Result<User, ApiError> {
let token = jar
.get(SESSION_COOKIE)
.map(Cookie::value)
.ok_or(ApiError::Unauthorized)?;
let user = session_user(&state.pool, token)
.await
.map_err(|_| ApiError::Internal)?
.ok_or(ApiError::Unauthorized)?;
if user.disabled {
return Err(ApiError::Unauthorized);
}
Ok(user)
}
async fn fetch_joinable_networks(pool: &PgPool, user: &User) -> Result<Vec<JoinableNetworkRow>, ApiError> {
if user.super_admin {
return sqlx::query_as::<_, JoinableNetworkRow>(
r#"
SELECT n.id,
n.control_plane_id,
c.name AS control_plane_name,
n.network_id,
n.name,
n.dns_domain,
n.overlay_v4,
n.overlay_v6,
n.requires_approval,
c.base_url,
c.admin_token
FROM networks n
JOIN control_planes c ON c.id = n.control_plane_id
ORDER BY n.created_at DESC
"#,
)
.fetch_all(pool)
.await
.map_err(|_| ApiError::Internal);
}
sqlx::query_as::<_, JoinableNetworkRow>(
r#"
SELECT n.id,
n.control_plane_id,
c.name AS control_plane_name,
n.network_id,
n.name,
n.dns_domain,
n.overlay_v4,
n.overlay_v6,
n.requires_approval,
c.base_url,
c.admin_token
FROM networks n
JOIN control_planes c ON c.id = n.control_plane_id
WHERE EXISTS (
SELECT 1
FROM memberships m
JOIN role_permissions rp ON rp.role_id = m.role_id
WHERE m.user_id = $1
AND (rp.permission = $2 OR rp.permission = $3)
AND (
(m.scope_type = 'global' AND m.scope_id = 'global')
OR (m.scope_type = $4 AND m.scope_id = n.id::text)
)
)
ORDER BY n.created_at DESC
"#,
)
.bind(user.id)
.bind(PERM_JOIN_TOKENS_CREATE)
.bind(PERM_TOKENS_WRITE)
.bind(JOIN_SCOPE_TYPE_NETWORK)
.fetch_all(pool)
.await
.map_err(|_| ApiError::Internal)
}
async fn fetch_network_for_join(pool: &PgPool, id: Uuid) -> Result<JoinableNetworkRow, ApiError> {
sqlx::query_as::<_, JoinableNetworkRow>(
r#"
SELECT n.id,
n.control_plane_id,
c.name AS control_plane_name,
n.network_id,
n.name,
n.dns_domain,
n.overlay_v4,
n.overlay_v6,
n.requires_approval,
c.base_url,
c.admin_token
FROM networks n
JOIN control_planes c ON c.id = n.control_plane_id
WHERE n.id = $1
"#,
)
.bind(id)
.fetch_optional(pool)
.await
.map_err(|_| ApiError::Internal)?
.ok_or(ApiError::NotFound)
}
async fn ensure_join_token_permission(pool: &PgPool, user: &User, network_id: Uuid) -> Result<(), ApiError> {
let scope_id = network_id.to_string();
match ensure_permission(
pool,
user,
PERM_JOIN_TOKENS_CREATE,
JOIN_SCOPE_TYPE_NETWORK,
&scope_id,
)
.await
{
Ok(()) => Ok(()),
Err(ApiError::Forbidden) => {
ensure_permission(pool, user, PERM_TOKENS_WRITE, JOIN_SCOPE_TYPE_NETWORK, &scope_id)
.await
}
Err(err) => Err(err),
}
}
fn normalize_join_token_ttl(value: Option<u64>) -> Result<u64, ApiError> {
let ttl = value.unwrap_or(DEFAULT_JOIN_TOKEN_TTL_SECONDS);
if ttl == 0 || ttl > MAX_JOIN_TOKEN_TTL_SECONDS {
return Err(ApiError::BadRequest(format!(
"ttl_seconds must be between 1 and {}",
MAX_JOIN_TOKEN_TTL_SECONDS
)));
}
Ok(ttl)
}
fn normalize_join_token_uses(value: Option<u32>) -> Result<u32, ApiError> {
let uses = value.unwrap_or(DEFAULT_JOIN_TOKEN_USES);
if uses == 0 || uses > MAX_JOIN_TOKEN_USES {
return Err(ApiError::BadRequest(format!(
"uses must be between 1 and {}",
MAX_JOIN_TOKEN_USES
)));
}
Ok(uses)
}

View file

@ -7,6 +7,7 @@ pub mod audit;
use crate::app_state::AppState; use crate::app_state::AppState;
use crate::auth::{session_user, SESSION_COOKIE}; use crate::auth::{session_user, SESSION_COOKIE};
use crate::models::User; use crate::models::User;
use crate::permissions::{ensure_permission_global, PERM_CONSOLE_ACCESS};
use axum::extract::FromRequestParts; use axum::extract::FromRequestParts;
use axum::http::{request::Parts, StatusCode}; use axum::http::{request::Parts, StatusCode};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
@ -70,6 +71,7 @@ impl FromRequestParts<AppState> for AuthUser {
if user.disabled { if user.disabled {
return Err(ApiError::Unauthorized); return Err(ApiError::Unauthorized);
} }
ensure_permission_global(&state.pool, &user, PERM_CONSOLE_ACCESS).await?;
Ok(Self { user }) Ok(Self { user })
} }
} }

View file

@ -13,6 +13,7 @@ use crate::permissions::{
PERM_NODES_WRITE, PERM_TOKENS_WRITE, PERM_AUDIT_READ, PERM_NODES_WRITE, PERM_TOKENS_WRITE, PERM_AUDIT_READ,
}; };
use axum::extract::{Path, Query, State}; use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::routing::{get, post}; use axum::routing::{get, post};
use axum::{Json, Router}; use axum::{Json, Router};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -40,6 +41,8 @@ struct NetworkSummary {
struct CreateNetworkRequest { struct CreateNetworkRequest {
control_plane_id: Uuid, control_plane_id: Uuid,
name: String, name: String,
overlay_v4: Option<String>,
overlay_v6: Option<String>,
dns_domain: Option<String>, dns_domain: Option<String>,
requires_approval: Option<bool>, requires_approval: Option<bool>,
key_rotation_max_age_seconds: Option<u64>, key_rotation_max_age_seconds: Option<u64>,
@ -86,7 +89,7 @@ struct NetworkAuditQuery {
pub fn router() -> Router<crate::app_state::AppState> { pub fn router() -> Router<crate::app_state::AppState> {
Router::new() Router::new()
.route("/", get(list_networks).post(create_network)) .route("/", get(list_networks).post(create_network))
.route("/:id", get(get_network)) .route("/:id", get(get_network).delete(delete_network))
.route("/:id/tokens", post(create_token)) .route("/:id/tokens", post(create_token))
.route("/:id/tokens/revoke", post(revoke_token)) .route("/:id/tokens/revoke", post(revoke_token))
.route("/:id/nodes", get(list_nodes)) .route("/:id/nodes", get(list_nodes))
@ -168,6 +171,44 @@ async fn get_network(
})) }))
} }
async fn delete_network(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_NETWORKS_WRITE).await?;
let (network, control_plane) = fetch_network_with_cp(&state.pool, id).await?;
let client = client_for(&control_plane);
// Send delete request to control plane
client
.delete_network(&network.network_id)
.await
.map_err(|_| ApiError::BadRequest("control plane request failed".to_string()))?;
// Delete from database
sqlx::query("DELETE FROM networks WHERE id = $1")
.bind(id)
.execute(&state.pool)
.await
.map_err(|_| ApiError::Internal)?;
audit_log::record(
&state.pool,
Some(user.id),
"network.delete",
Some("network"),
Some(&network.id.to_string()),
Some(serde_json::json!({
"network_id": network.network_id,
"control_plane_id": control_plane.id,
})),
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
async fn create_network( async fn create_network(
State(state): State<crate::app_state::AppState>, State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser, AuthUser { user }: AuthUser,
@ -183,6 +224,8 @@ async fn create_network(
let response = client let response = client
.create_network(CpCreateNetworkRequest { .create_network(CpCreateNetworkRequest {
name: req.name, name: req.name,
overlay_v4: req.overlay_v4.clone(),
overlay_v6: req.overlay_v6.clone(),
dns_domain: req.dns_domain.clone(), dns_domain: req.dns_domain.clone(),
requires_approval: req.requires_approval, requires_approval: req.requires_approval,
key_rotation_max_age_seconds: req.key_rotation_max_age_seconds, key_rotation_max_age_seconds: req.key_rotation_max_age_seconds,
@ -249,6 +292,9 @@ async fn create_token(
ttl_seconds: req.ttl_seconds, ttl_seconds: req.ttl_seconds,
uses: req.uses, uses: req.uses,
tags: req.tags, tags: req.tags,
owner_user_id: Some(user.id.to_string()),
owner_email: Some(user.email.clone()),
owner_is_admin: Some(true),
}, },
) )
.await .await

View file

@ -27,6 +27,8 @@ pub struct DatabaseConfig {
pub url: String, pub url: String,
#[serde(default = "default_max_connections")] #[serde(default = "default_max_connections")]
pub max_connections: u32, pub max_connections: u32,
#[serde(default = "default_disable_migration_locking")]
pub disable_migration_locking: bool,
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
@ -80,6 +82,10 @@ fn default_max_connections() -> u32 {
20 20
} }
fn default_disable_migration_locking() -> bool {
false
}
fn default_session_ttl_minutes() -> i64 { fn default_session_ttl_minutes() -> i64 {
720 720
} }

View file

@ -25,7 +25,7 @@ impl ControlPlaneClient {
fn auth_header(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder { fn auth_header(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if let Some(token) = &self.admin_token { if let Some(token) = &self.admin_token {
req.header("X-Lightscale-Admin-Token", token) req.bearer_auth(token)
} else { } else {
req req
} }
@ -199,11 +199,25 @@ impl ControlPlaneClient {
.map_err(|err| anyhow!("audit log failed: {}", err))?; .map_err(|err| anyhow!("audit log failed: {}", err))?;
Ok(resp.json().await?) Ok(resp.json().await?)
} }
pub async fn delete_network(&self, network_id: &str) -> Result<()> {
let req = self
.client
.delete(self.url(&format!("/v1/networks/{}", network_id)));
self.auth_header(req)
.send()
.await?
.error_for_status()
.map_err(|err| anyhow!("delete network failed: {}", err))?;
Ok(())
}
} }
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CreateNetworkRequest { pub struct CreateNetworkRequest {
pub name: String, pub name: String,
pub overlay_v4: Option<String>,
pub overlay_v6: Option<String>,
pub dns_domain: Option<String>, pub dns_domain: Option<String>,
pub requires_approval: Option<bool>, pub requires_approval: Option<bool>,
pub key_rotation_max_age_seconds: Option<u64>, pub key_rotation_max_age_seconds: Option<u64>,
@ -236,6 +250,12 @@ pub struct EnrollmentToken {
pub expires_at: i64, pub expires_at: i64,
pub uses_left: u32, pub uses_left: u32,
pub tags: Vec<String>, pub tags: Vec<String>,
#[serde(default)]
pub owner_user_id: Option<String>,
#[serde(default)]
pub owner_email: Option<String>,
#[serde(default)]
pub owner_is_admin: bool,
pub revoked_at: Option<i64>, pub revoked_at: Option<i64>,
} }
@ -244,6 +264,12 @@ pub struct CreateTokenRequest {
pub ttl_seconds: u64, pub ttl_seconds: u64,
pub uses: u32, pub uses: u32,
pub tags: Vec<String>, pub tags: Vec<String>,
#[serde(default)]
pub owner_user_id: Option<String>,
#[serde(default)]
pub owner_email: Option<String>,
#[serde(default)]
pub owner_is_admin: Option<bool>,
} }
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
@ -267,6 +293,12 @@ pub struct NodeInfo {
pub machine_public_key: String, pub machine_public_key: String,
pub endpoints: Vec<String>, pub endpoints: Vec<String>,
pub tags: Vec<String>, pub tags: Vec<String>,
#[serde(default)]
pub owner_user_id: Option<String>,
#[serde(default)]
pub owner_email: Option<String>,
#[serde(default)]
pub owner_is_admin: bool,
pub routes: Vec<Route>, pub routes: Vec<Route>,
pub last_seen: i64, pub last_seen: i64,
pub approved: bool, pub approved: bool,
@ -364,3 +396,47 @@ pub struct AuditLogQuery {
pub node_id: Option<String>, pub node_id: Option<String>,
pub limit: Option<usize>, pub limit: Option<usize>,
} }
#[cfg(test)]
mod tests {
use super::*;
use reqwest::header::AUTHORIZATION;
#[test]
fn auth_header_uses_bearer_token() {
let client = ControlPlaneClient::new(
"https://cp.example.com".to_string(),
Some("topsecret".to_string()),
);
let request = client
.auth_header(client.client.get(client.url("/healthz")))
.build()
.expect("request should build");
let auth = request
.headers()
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok());
assert_eq!(auth, Some("Bearer topsecret"));
}
#[test]
fn create_network_request_serializes_overlay_cidrs() {
let request = CreateNetworkRequest {
name: "lab".to_string(),
overlay_v4: Some("100.120.0.0/24".to_string()),
overlay_v6: Some("fd42:120:0::/48".to_string()),
dns_domain: Some("lab.lightscale".to_string()),
requires_approval: Some(true),
key_rotation_max_age_seconds: Some(3600),
bootstrap_token_ttl_seconds: Some(600),
bootstrap_token_uses: Some(1),
bootstrap_token_tags: Some(vec!["dev".to_string()]),
};
let json = serde_json::to_value(request).expect("request should serialize");
assert_eq!(json.get("overlay_v4").and_then(Value::as_str), Some("100.120.0.0/24"));
assert_eq!(
json.get("overlay_v6").and_then(Value::as_str),
Some("fd42:120:0::/48")
);
}
}

View file

@ -7,3 +7,8 @@ pub async fn connect(config: &DatabaseConfig) -> Result<PgPool, sqlx::Error> {
.connect(&config.url) .connect(&config.url)
.await .await
} }
pub async fn is_cockroach(pool: &PgPool) -> Result<bool, sqlx::Error> {
let version: String = sqlx::query_scalar("SELECT version()").fetch_one(pool).await?;
Ok(version.contains("CockroachDB"))
}

View file

@ -29,7 +29,30 @@ async fn main() -> anyhow::Result<()> {
let config = Config::load()?; let config = Config::load()?;
let pool = db::connect(&config.database).await?; let pool = db::connect(&config.database).await?;
sqlx::migrate!("./migrations").run(&pool).await?;
let mut disable_migration_locking = config.database.disable_migration_locking;
if !disable_migration_locking {
match db::is_cockroach(&pool).await {
Ok(true) => {
tracing::info!("detected CockroachDB, disabling SQLx migration locking");
disable_migration_locking = true;
}
Ok(false) => {}
Err(err) => {
tracing::warn!(
error = ?err,
"failed to detect database engine, keeping migration locking enabled"
);
}
}
}
let mut migrator = sqlx::migrate!("./migrations");
if disable_migration_locking {
// CockroachDB does not implement pg_advisory_lock used by SQLx migration locking.
migrator.set_locking(false);
}
migrator.run(&pool).await?;
ensure_bootstrap_admin(&pool, &config).await?; ensure_bootstrap_admin(&pool, &config).await?;

View file

@ -5,11 +5,13 @@ use sqlx::PgPool;
pub const PERM_CONTROL_PLANES_READ: &str = "control_planes:read"; pub const PERM_CONTROL_PLANES_READ: &str = "control_planes:read";
pub const PERM_CONTROL_PLANES_WRITE: &str = "control_planes:write"; pub const PERM_CONTROL_PLANES_WRITE: &str = "control_planes:write";
pub const PERM_CONSOLE_ACCESS: &str = "console:access";
pub const PERM_NETWORKS_READ: &str = "networks:read"; pub const PERM_NETWORKS_READ: &str = "networks:read";
pub const PERM_NETWORKS_WRITE: &str = "networks:write"; pub const PERM_NETWORKS_WRITE: &str = "networks:write";
pub const PERM_NODES_READ: &str = "nodes:read"; pub const PERM_NODES_READ: &str = "nodes:read";
pub const PERM_NODES_WRITE: &str = "nodes:write"; pub const PERM_NODES_WRITE: &str = "nodes:write";
pub const PERM_TOKENS_WRITE: &str = "tokens:write"; pub const PERM_TOKENS_WRITE: &str = "tokens:write";
pub const PERM_JOIN_TOKENS_CREATE: &str = "join_tokens:create";
pub const PERM_ACL_READ: &str = "acl:read"; pub const PERM_ACL_READ: &str = "acl:read";
pub const PERM_ACL_WRITE: &str = "acl:write"; pub const PERM_ACL_WRITE: &str = "acl:write";
pub const PERM_KEY_POLICY_READ: &str = "key_policy:read"; pub const PERM_KEY_POLICY_READ: &str = "key_policy:read";
@ -47,3 +49,33 @@ pub async fn ensure_permission(
Err(ApiError::Forbidden) Err(ApiError::Forbidden)
} }
} }
pub async fn list_permissions_global(pool: &PgPool, user: &User) -> Result<Vec<String>, ApiError> {
if user.super_admin {
let permissions = sqlx::query_scalar::<_, String>(
"SELECT DISTINCT permission FROM role_permissions ORDER BY permission",
)
.fetch_all(pool)
.await
.map_err(|_| ApiError::Internal)?;
return Ok(permissions);
}
let permissions = sqlx::query_scalar::<_, String>(
"\
SELECT DISTINCT rp.permission\n\
FROM memberships m\n\
JOIN role_permissions rp ON rp.role_id = m.role_id\n\
WHERE m.user_id = $1\n\
AND m.scope_type = 'global'\n\
AND m.scope_id = 'global'\n\
ORDER BY rp.permission\n\
",
)
.bind(user.id)
.fetch_all(pool)
.await
.map_err(|_| ApiError::Internal)?;
Ok(permissions)
}

View file

@ -7,6 +7,8 @@ static_dir = "frontend/dist"
[database] [database]
url = "postgresql://root@localhost:26257/lightscale_admin?sslmode=disable" url = "postgresql://root@localhost:26257/lightscale_admin?sslmode=disable"
max_connections = 20 max_connections = 20
# Optional override. CockroachDB is auto-detected and migration locking is disabled automatically.
disable_migration_locking = true
[auth] [auth]
session_ttl_minutes = 720 session_ttl_minutes = 720

View file

@ -7,7 +7,9 @@ import {
createNetwork, createNetwork,
createToken, createToken,
createUser, createUser,
deleteControlPlane,
deleteMembership, deleteMembership,
deleteNetwork,
getAcl, getAcl,
getKeyPolicy, getKeyPolicy,
getMe, getMe,
@ -42,6 +44,8 @@ import {
type KeyPolicyResponse, type KeyPolicyResponse,
type NetworkSummary, type NetworkSummary,
type NodeInfo, type NodeInfo,
type AuthSession,
type AuthUserSummary,
type ProviderSummary, type ProviderSummary,
type RoleSummary, type RoleSummary,
type UserDetail, type UserDetail,
@ -82,12 +86,12 @@ const DEFAULT_SCOPE = { scope_type: 'global', scope_id: 'global' }
function App() { function App() {
const [authReady, setAuthReady] = useState(false) const [authReady, setAuthReady] = useState(false)
const [currentUser, setCurrentUser] = useState<UserSummary | null>(null) const [session, setSession] = useState<AuthSession | null>(null)
useEffect(() => { useEffect(() => {
getMe() getMe()
.then((user) => setCurrentUser(user)) .then((me) => setSession(me))
.catch(() => setCurrentUser(null)) .catch(() => setSession(null))
.finally(() => setAuthReady(true)) .finally(() => setAuthReady(true))
}, []) }, [])
@ -99,20 +103,46 @@ function App() {
) )
} }
if (!currentUser) { if (!session) {
return <LoginScreen onAuthed={setCurrentUser} /> return <LoginScreen onAuthed={setSession} />
}
if (!session.console_access) {
return (
<div className="screen center">
<div className="card" style={{ maxWidth: 560 }}>
<h3>Console access is not granted</h3>
<p className="muted">
This account is authenticated, but it does not have the
<code>console:access</code> permission.
</p>
<div className="button-row">
<button
className="ghost"
onClick={async () => {
await logout()
setSession(null)
}}
>
Sign out
</button>
</div>
</div>
</div>
)
} }
return ( return (
<AdminShell <AdminShell
user={currentUser} user={session.user}
onLogout={() => setCurrentUser(null)} permissions={session.permissions}
onUserUpdated={setCurrentUser} onLogout={() => setSession(null)}
onSessionUpdated={setSession}
/> />
) )
} }
function LoginScreen({ onAuthed }: { onAuthed: (user: UserSummary) => void }) { function LoginScreen({ onAuthed }: { onAuthed: (session: AuthSession) => void }) {
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [providers, setProviders] = useState<ProviderSummary[]>([]) const [providers, setProviders] = useState<ProviderSummary[]>([])
@ -131,7 +161,7 @@ function LoginScreen({ onAuthed }: { onAuthed: (user: UserSummary) => void }) {
setError(null) setError(null)
try { try {
const response = await login(email, password) const response = await login(email, password)
onAuthed(response.user) onAuthed(response)
} catch (err) { } catch (err) {
setError((err as Error).message) setError((err as Error).message)
} finally { } finally {
@ -223,12 +253,14 @@ function LoginScreen({ onAuthed }: { onAuthed: (user: UserSummary) => void }) {
function AdminShell({ function AdminShell({
user, user,
permissions,
onLogout, onLogout,
onUserUpdated, onSessionUpdated,
}: { }: {
user: UserSummary user: AuthUserSummary
permissions: string[]
onLogout: () => void onLogout: () => void
onUserUpdated: (user: UserSummary) => void onSessionUpdated: (session: AuthSession) => void
}) { }) {
const [active, setActive] = useState<SectionId>('overview') const [active, setActive] = useState<SectionId>('overview')
const [banner, setBanner] = useState<BannerMessage | null>(null) const [banner, setBanner] = useState<BannerMessage | null>(null)
@ -259,12 +291,85 @@ function AdminShell({
const selectedNodeCount = nodesResponse?.nodes.length ?? 0 const selectedNodeCount = nodesResponse?.nodes.length ?? 0
const permissionSet = useMemo(() => new Set(permissions), [permissions])
const hasPermission = (permission: string) =>
user.super_admin || permissionSet.has(permission)
const canControlPlanesRead = hasPermission('control_planes:read')
const canControlPlanesWrite = hasPermission('control_planes:write')
const canNetworksRead = hasPermission('networks:read')
const canNetworksWrite = hasPermission('networks:write')
const canNodesRead = hasPermission('nodes:read')
const canNodesWrite = hasPermission('nodes:write')
const canTokensWrite = hasPermission('tokens:write')
const canAclRead = hasPermission('acl:read')
const canAclWrite = hasPermission('acl:write')
const canKeyPolicyRead = hasPermission('key_policy:read')
const canKeyPolicyWrite = hasPermission('key_policy:write')
const canUsersRead = hasPermission('users:read')
const canUsersWrite = hasPermission('users:write')
const canRolesRead = hasPermission('roles:read')
const canAuditRead = hasPermission('audit:read')
const visibleSections = useMemo(
() =>
sections.filter((section) => {
switch (section.id) {
case 'overview':
return true
case 'control-planes':
return canControlPlanesRead
case 'networks':
return canNetworksRead
case 'nodes':
return canNodesRead
case 'tokens':
return canTokensWrite
case 'acl':
return canAclRead
case 'key-policy':
return canKeyPolicyRead
case 'users':
return canUsersRead
case 'audit':
return canAuditRead
}
}),
[
canAclRead,
canAuditRead,
canControlPlanesRead,
canKeyPolicyRead,
canNetworksRead,
canNodesRead,
canTokensWrite,
canUsersRead,
],
)
useEffect(() => { useEffect(() => {
if (canControlPlanesRead) {
refreshControlPlanes() refreshControlPlanes()
}
if (canNetworksRead) {
refreshNetworks() refreshNetworks()
}
if (canRolesRead) {
refreshRoles() refreshRoles()
}
if (canUsersRead) {
refreshUsers() refreshUsers()
}, []) }
}, [canControlPlanesRead, canNetworksRead, canRolesRead, canUsersRead])
useEffect(() => {
if (visibleSections.some((section) => section.id === active)) {
return
}
if (visibleSections.length > 0) {
setActive(visibleSections[0].id)
}
}, [active, visibleSections])
useEffect(() => { useEffect(() => {
if (controlPlanes.length && !selectedControlPlaneId) { if (controlPlanes.length && !selectedControlPlaneId) {
@ -280,23 +385,35 @@ function AdminShell({
useEffect(() => { useEffect(() => {
if (!selectedNetworkId) return if (!selectedNetworkId) return
if (canNodesRead) {
listNodes(selectedNetworkId) listNodes(selectedNetworkId)
.then(setNodesResponse) .then(setNodesResponse)
.catch((err) => handleError(err, 'error')) .catch((err) => handleError(err, 'error'))
} else {
setNodesResponse(null)
}
if (canAclRead) {
getAcl(selectedNetworkId) getAcl(selectedNetworkId)
.then((policy) => setAclText(JSON.stringify(policy, null, 2))) .then((policy) => setAclText(JSON.stringify(policy, null, 2)))
.catch(() => setAclText('')) .catch(() => setAclText(''))
} else {
setAclText('')
}
if (canKeyPolicyRead) {
getKeyPolicy(selectedNetworkId) getKeyPolicy(selectedNetworkId)
.then(setKeyPolicy) .then(setKeyPolicy)
.catch(() => setKeyPolicy(null)) .catch(() => setKeyPolicy(null))
}, [selectedNetworkId]) } else {
setKeyPolicy(null)
}
}, [selectedNetworkId, canAclRead, canKeyPolicyRead, canNodesRead])
useEffect(() => { useEffect(() => {
if (active !== 'audit') return if (active !== 'audit' || !canAuditRead) return
listAdminAudit({ limit: 200 }) listAdminAudit({ limit: 200 })
.then(setAdminAudit) .then(setAdminAudit)
.catch((err) => handleError(err, 'error')) .catch((err) => handleError(err, 'error'))
}, [active]) }, [active, canAuditRead])
const refreshControlPlanes = async () => { const refreshControlPlanes = async () => {
try { try {
@ -359,7 +476,11 @@ function AdminShell({
const handleUpdateCurrentUser = async () => { const handleUpdateCurrentUser = async () => {
try { try {
const refreshed = await getMe() const refreshed = await getMe()
onUserUpdated(refreshed) if (!refreshed.console_access) {
onLogout()
return
}
onSessionUpdated(refreshed)
} catch (err) { } catch (err) {
handleError(err, 'warning') handleError(err, 'warning')
} }
@ -412,7 +533,7 @@ function AdminShell({
</div> </div>
</div> </div>
<nav className="nav"> <nav className="nav">
{sections.map((section) => ( {visibleSections.map((section) => (
<button <button
key={section.id} key={section.id}
className={active === section.id ? 'active' : ''} className={active === section.id ? 'active' : ''}
@ -437,7 +558,7 @@ function AdminShell({
<main className="main"> <main className="main">
<header className="topbar"> <header className="topbar">
<div> <div>
<h1>{sections.find((section) => section.id === active)?.label}</h1> <h1>{visibleSections.find((section) => section.id === active)?.label || 'Overview'}</h1>
<p> <p>
{selectedNetwork {selectedNetwork
? `${selectedNetwork.name} · ${selectedNetwork.control_plane_name}` ? `${selectedNetwork.name} · ${selectedNetwork.control_plane_name}`
@ -501,12 +622,13 @@ function AdminShell({
<div className="card"> <div className="card">
<h3>Active signals</h3> <h3>Active signals</h3>
<p>Audit log for admin actions and control plane events.</p> <p>Audit log for admin actions and control plane events.</p>
<button {canAuditRead ? (
className="ghost" <button className="ghost" onClick={() => setActive('audit')}>
onClick={() => setActive('audit')}
>
View audit timeline View audit timeline
</button> </button>
) : (
<p className="muted">No audit permission.</p>
)}
</div> </div>
<div className="card"> <div className="card">
<h3>Role coverage</h3> <h3>Role coverage</h3>
@ -514,23 +636,32 @@ function AdminShell({
{roles.length} roles configured. {users.length} users in {roles.length} roles configured. {users.length} users in
directory. directory.
</p> </p>
{canUsersRead ? (
<button className="ghost" onClick={() => setActive('users')}> <button className="ghost" onClick={() => setActive('users')}>
Manage access Manage access
</button> </button>
) : (
<p className="muted">No user-directory permission.</p>
)}
</div> </div>
<div className="card"> <div className="card">
<h3>Network posture</h3> <h3>Network posture</h3>
<p>Keep ACLs tight and key rotation moving.</p> <p>Keep ACLs tight and key rotation moving.</p>
{canAclRead ? (
<button className="ghost" onClick={() => setActive('acl')}> <button className="ghost" onClick={() => setActive('acl')}>
Edit ACL Edit ACL
</button> </button>
) : (
<p className="muted">No ACL permission.</p>
)}
</div> </div>
</div> </div>
)} )}
{active === 'control-planes' && ( {active === 'control-planes' && canControlPlanesRead && (
<ControlPlaneSection <ControlPlaneSection
controlPlanes={controlPlanes} controlPlanes={controlPlanes}
canWrite={canControlPlanesWrite}
onCreate={async (payload) => { onCreate={async (payload) => {
const created = await createControlPlane(payload) const created = await createControlPlane(payload)
setControlPlanes((prev) => [...prev, created]) setControlPlanes((prev) => [...prev, created])
@ -542,31 +673,41 @@ function AdminShell({
) )
}} }}
onVerify={async (id) => verifyControlPlane(id)} onVerify={async (id) => verifyControlPlane(id)}
onDelete={async (id) => {
await deleteControlPlane(id)
setControlPlanes((prev) => prev.filter((plane) => plane.id !== id))
}}
onBanner={setBanner} onBanner={setBanner}
/> />
)} )}
{active === 'networks' && ( {active === 'networks' && canNetworksRead && (
<NetworksSection <NetworksSection
controlPlanes={controlPlanes} controlPlanes={controlPlanes}
networks={networks} networks={networks}
canWrite={canNetworksWrite}
onCreate={async (payload) => { onCreate={async (payload) => {
const result = await createNetwork(payload) const result = await createNetwork(payload)
setNetworks((prev) => [...prev, result.network]) setNetworks((prev) => [...prev, result.network])
setBootstrapToken(result.bootstrap_token || null) setBootstrapToken(result.bootstrap_token || null)
setSelectedNetworkId(result.network.id) setSelectedNetworkId(result.network.id)
}} }}
onDelete={async (id) => {
await deleteNetwork(id)
setNetworks((prev) => prev.filter((network) => network.id !== id))
}}
onSelect={(id) => setSelectedNetworkId(id)} onSelect={(id) => setSelectedNetworkId(id)}
selectedNetworkId={selectedNetworkId} selectedNetworkId={selectedNetworkId}
bootstrapToken={bootstrapToken} bootstrapToken={bootstrapToken}
/> />
)} )}
{active === 'nodes' && ( {active === 'nodes' && canNodesRead && (
<NodesSection <NodesSection
network={selectedNetwork} network={selectedNetwork}
nodes={nodesResponse?.nodes || []} nodes={nodesResponse?.nodes || []}
keyHistory={keyHistory} keyHistory={keyHistory}
canWrite={canNodesWrite}
onApprove={(nodeId) => onApprove={(nodeId) =>
handleNodeAction(() => approveNode(selectedNetworkId, nodeId)) handleNodeAction(() => approveNode(selectedNetworkId, nodeId))
} }
@ -588,7 +729,7 @@ function AdminShell({
/> />
)} )}
{active === 'tokens' && ( {active === 'tokens' && canTokensWrite && (
<TokensSection <TokensSection
network={selectedNetwork} network={selectedNetwork}
tokenResult={tokenResult} tokenResult={tokenResult}
@ -607,28 +748,31 @@ function AdminShell({
/> />
)} )}
{active === 'acl' && ( {active === 'acl' && canAclRead && (
<AclSection <AclSection
network={selectedNetwork} network={selectedNetwork}
aclText={aclText} aclText={aclText}
canWrite={canAclWrite}
onChange={setAclText} onChange={setAclText}
onSave={handleAclSave} onSave={handleAclSave}
/> />
)} )}
{active === 'key-policy' && ( {active === 'key-policy' && canKeyPolicyRead && (
<KeyPolicySection <KeyPolicySection
network={selectedNetwork} network={selectedNetwork}
policy={keyPolicy} policy={keyPolicy}
canWrite={canKeyPolicyWrite}
onSave={handleKeyPolicySave} onSave={handleKeyPolicySave}
/> />
)} )}
{active === 'users' && ( {active === 'users' && canUsersRead && (
<UsersSection <UsersSection
users={users} users={users}
roles={roles} roles={roles}
selectedUser={selectedUser} selectedUser={selectedUser}
canWrite={canUsersWrite}
onSelect={handleSelectUser} onSelect={handleSelectUser}
onCreate={async (payload) => { onCreate={async (payload) => {
const created = await createUser(payload) const created = await createUser(payload)
@ -641,7 +785,10 @@ function AdminShell({
prev.map((record) => (record.id === updated.id ? updated : record)), prev.map((record) => (record.id === updated.id ? updated : record)),
) )
if (selectedUser && selectedUser.user.id === updated.id) { if (selectedUser && selectedUser.user.id === updated.id) {
setSelectedUser({ ...selectedUser, user: updated }) setSelectedUser({
...selectedUser,
user: updated,
})
} }
}} }}
onSetPassword={async (userId, password) => { onSetPassword={async (userId, password) => {
@ -670,7 +817,7 @@ function AdminShell({
/> />
)} )}
{active === 'audit' && ( {active === 'audit' && canAuditRead && (
<AuditSection <AuditSection
controlPlanes={controlPlanes} controlPlanes={controlPlanes}
adminAudit={adminAudit} adminAudit={adminAudit}
@ -693,12 +840,15 @@ function AdminShell({
function ControlPlaneSection({ function ControlPlaneSection({
controlPlanes, controlPlanes,
canWrite,
onCreate, onCreate,
onUpdate, onUpdate,
onVerify, onVerify,
onDelete,
onBanner, onBanner,
}: { }: {
controlPlanes: ControlPlaneSummary[] controlPlanes: ControlPlaneSummary[]
canWrite: boolean
onCreate: (payload: { onCreate: (payload: {
name: string name: string
base_url: string base_url: string
@ -716,6 +866,7 @@ function ControlPlaneSection({
}, },
) => Promise<void> ) => Promise<void>
onVerify: (id: string) => Promise<{ ok: boolean; status?: number; body?: string }> onVerify: (id: string) => Promise<{ ok: boolean; status?: number; body?: string }>
onDelete: (id: string) => Promise<void>
onBanner: (message: BannerMessage) => void onBanner: (message: BannerMessage) => void
}) { }) {
const [form, setForm] = useState({ const [form, setForm] = useState({
@ -741,6 +892,7 @@ function ControlPlaneSection({
const handleSubmit = async (event: FormEvent) => { const handleSubmit = async (event: FormEvent) => {
event.preventDefault() event.preventDefault()
if (!canWrite) return
setLoading(true) setLoading(true)
try { try {
if (form.id) { if (form.id) {
@ -820,7 +972,7 @@ function ControlPlaneSection({
/> />
</label> </label>
<div className="button-row"> <div className="button-row">
<button type="submit" disabled={loading}> <button type="submit" disabled={loading || !canWrite}>
{loading ? 'Saving...' : form.id ? 'Update plane' : 'Create plane'} {loading ? 'Saving...' : form.id ? 'Update plane' : 'Create plane'}
</button> </button>
{form.id && ( {form.id && (
@ -861,6 +1013,8 @@ function ControlPlaneSection({
> >
Verify Verify
</button> </button>
{canWrite && (
<>
<button <button
className="ghost" className="ghost"
onClick={() => onClick={() =>
@ -876,6 +1030,18 @@ function ControlPlaneSection({
> >
Edit Edit
</button> </button>
<button
className="ghost danger"
onClick={() => {
if (window.confirm(`Delete control plane "${plane.name}"?`)) {
onDelete(plane.id)
}
}}
>
Delete
</button>
</>
)}
</div> </div>
</div> </div>
))} ))}
@ -891,16 +1057,21 @@ function ControlPlaneSection({
function NetworksSection({ function NetworksSection({
controlPlanes, controlPlanes,
networks, networks,
canWrite,
onCreate, onCreate,
onDelete,
onSelect, onSelect,
selectedNetworkId, selectedNetworkId,
bootstrapToken, bootstrapToken,
}: { }: {
controlPlanes: ControlPlaneSummary[] controlPlanes: ControlPlaneSummary[]
networks: NetworkSummary[] networks: NetworkSummary[]
canWrite: boolean
onCreate: (payload: { onCreate: (payload: {
control_plane_id: string control_plane_id: string
name: string name: string
overlay_v4?: string
overlay_v6?: string
dns_domain?: string dns_domain?: string
requires_approval?: boolean requires_approval?: boolean
key_rotation_max_age_seconds?: number key_rotation_max_age_seconds?: number
@ -908,6 +1079,7 @@ function NetworksSection({
bootstrap_token_uses?: number bootstrap_token_uses?: number
bootstrap_token_tags?: string[] bootstrap_token_tags?: string[]
}) => Promise<void> }) => Promise<void>
onDelete: (id: string) => Promise<void>
onSelect: (id: string) => void onSelect: (id: string) => void
selectedNetworkId: string selectedNetworkId: string
bootstrapToken: EnrollmentToken | null bootstrapToken: EnrollmentToken | null
@ -915,6 +1087,8 @@ function NetworksSection({
const [form, setForm] = useState({ const [form, setForm] = useState({
control_plane_id: '', control_plane_id: '',
name: '', name: '',
overlay_v4: '',
overlay_v6: '',
dns_domain: '', dns_domain: '',
requires_approval: false, requires_approval: false,
key_rotation_max_age_seconds: '', key_rotation_max_age_seconds: '',
@ -925,10 +1099,13 @@ function NetworksSection({
const handleSubmit = async (event: FormEvent) => { const handleSubmit = async (event: FormEvent) => {
event.preventDefault() event.preventDefault()
if (!canWrite) return
if (!form.control_plane_id) return if (!form.control_plane_id) return
await onCreate({ await onCreate({
control_plane_id: form.control_plane_id, control_plane_id: form.control_plane_id,
name: form.name, name: form.name,
overlay_v4: form.overlay_v4 || undefined,
overlay_v6: form.overlay_v6 || undefined,
dns_domain: form.dns_domain || undefined, dns_domain: form.dns_domain || undefined,
requires_approval: form.requires_approval, requires_approval: form.requires_approval,
key_rotation_max_age_seconds: form.key_rotation_max_age_seconds key_rotation_max_age_seconds: form.key_rotation_max_age_seconds
@ -947,6 +1124,8 @@ function NetworksSection({
setForm({ setForm({
control_plane_id: form.control_plane_id, control_plane_id: form.control_plane_id,
name: '', name: '',
overlay_v4: '',
overlay_v6: '',
dns_domain: '', dns_domain: '',
requires_approval: false, requires_approval: false,
key_rotation_max_age_seconds: '', key_rotation_max_age_seconds: '',
@ -986,6 +1165,28 @@ function NetworksSection({
required required
/> />
</label> </label>
<div className="inline">
<label>
<span>Overlay IPv4 CIDR</span>
<input
value={form.overlay_v4}
onChange={(event) =>
setForm({ ...form, overlay_v4: event.target.value })
}
placeholder="100.120.0.0/24"
/>
</label>
<label>
<span>Overlay IPv6 CIDR</span>
<input
value={form.overlay_v6}
onChange={(event) =>
setForm({ ...form, overlay_v6: event.target.value })
}
placeholder="fd42:120:0::/48"
/>
</label>
</div>
<label> <label>
<span>DNS domain</span> <span>DNS domain</span>
<input <input
@ -1056,7 +1257,7 @@ function NetworksSection({
/> />
</label> </label>
</div> </div>
<button type="submit">Create network</button> <button type="submit" disabled={!canWrite}>Create network</button>
</form> </form>
{bootstrapToken && ( {bootstrapToken && (
@ -1074,15 +1275,17 @@ function NetworksSection({
<h3>Known networks</h3> <h3>Known networks</h3>
<div className="list"> <div className="list">
{networks.map((network, index) => ( {networks.map((network, index) => (
<button <div
key={network.id} key={network.id}
className={`list-row selectable ${ className={`list-row ${
network.id === selectedNetworkId ? 'active' : '' network.id === selectedNetworkId ? 'active' : ''
}`} }`}
style={{ animationDelay: `${index * 40}ms` }} style={{ animationDelay: `${index * 40}ms` }}
>
<div
className="selectable-area"
onClick={() => onSelect(network.id)} onClick={() => onSelect(network.id)}
> >
<div>
<strong>{network.name}</strong> <strong>{network.name}</strong>
<p>{network.network_id}</p> <p>{network.network_id}</p>
<span className="tag">{network.control_plane_name}</span> <span className="tag">{network.control_plane_name}</span>
@ -1091,7 +1294,21 @@ function NetworksSection({
)} )}
</div> </div>
<div className="mono">{network.overlay_v4 || '-'}</div> <div className="mono">{network.overlay_v4 || '-'}</div>
<div className="button-row">
{canWrite && (
<button
className="ghost danger"
onClick={() => {
if (window.confirm(`Delete network "${network.name}"?`)) {
onDelete(network.id)
}
}}
>
Delete
</button> </button>
)}
</div>
</div>
))} ))}
{networks.length === 0 && <p className="muted">No networks yet.</p>} {networks.length === 0 && <p className="muted">No networks yet.</p>}
</div> </div>
@ -1104,6 +1321,7 @@ function NodesSection({
network, network,
nodes, nodes,
keyHistory, keyHistory,
canWrite,
onApprove, onApprove,
onRevoke, onRevoke,
onRotate, onRotate,
@ -1112,6 +1330,7 @@ function NodesSection({
network: NetworkSummary | null network: NetworkSummary | null
nodes: NodeInfo[] nodes: NodeInfo[]
keyHistory: KeyHistoryResponse | null keyHistory: KeyHistoryResponse | null
canWrite: boolean
onApprove: (nodeId: string) => void onApprove: (nodeId: string) => void
onRevoke: (nodeId: string) => void onRevoke: (nodeId: string) => void
onRotate: (nodeId: string) => void onRotate: (nodeId: string) => void
@ -1137,25 +1356,29 @@ function NodesSection({
) : ( ) : (
<span className="tag warning">pending</span> <span className="tag warning">pending</span>
)} )}
{node.owner_is_admin && <span className="tag">admin-issued</span>}
{node.revoked && <span className="tag danger">revoked</span>} {node.revoked && <span className="tag danger">revoked</span>}
{node.key_rotation_required && ( {node.key_rotation_required && (
<span className="tag warning">rotate keys</span> <span className="tag warning">rotate keys</span>
)} )}
</div> </div>
{node.owner_email && <small>Owner: {node.owner_email}</small>}
<small>Last seen: {formatEpoch(node.last_seen)}</small> <small>Last seen: {formatEpoch(node.last_seen)}</small>
</div> </div>
<div className="button-row"> <div className="button-row">
{!node.approved && !node.revoked && ( {canWrite && !node.approved && !node.revoked && (
<button onClick={() => onApprove(node.id)}>Approve</button> <button onClick={() => onApprove(node.id)}>Approve</button>
)} )}
{!node.revoked && ( {canWrite && !node.revoked && (
<button className="ghost" onClick={() => onRevoke(node.id)}> <button className="ghost" onClick={() => onRevoke(node.id)}>
Revoke Revoke
</button> </button>
)} )}
{canWrite && (
<button className="ghost" onClick={() => onRotate(node.id)}> <button className="ghost" onClick={() => onRotate(node.id)}>
Rotate keys Rotate keys
</button> </button>
)}
<button className="ghost" onClick={() => onViewKeys(node.id)}> <button className="ghost" onClick={() => onViewKeys(node.id)}>
View keys View keys
</button> </button>
@ -1267,6 +1490,14 @@ function TokensSection({
<span>Uses left: {tokenResult.token.uses_left}</span> <span>Uses left: {tokenResult.token.uses_left}</span>
<span>Expires: {formatEpoch(tokenResult.token.expires_at)}</span> <span>Expires: {formatEpoch(tokenResult.token.expires_at)}</span>
</div> </div>
{(tokenResult.token.owner_email || tokenResult.token.owner_is_admin) && (
<div className="meta">
{tokenResult.token.owner_email && (
<span>Owner: {tokenResult.token.owner_email}</span>
)}
{tokenResult.token.owner_is_admin && <span>Privilege: admin</span>}
</div>
)}
</div> </div>
)} )}
</div> </div>
@ -1305,11 +1536,13 @@ function TokensSection({
function AclSection({ function AclSection({
network, network,
aclText, aclText,
canWrite,
onChange, onChange,
onSave, onSave,
}: { }: {
network: NetworkSummary | null network: NetworkSummary | null
aclText: string aclText: string
canWrite: boolean
onChange: (value: string) => void onChange: (value: string) => void
onSave: () => void onSave: () => void
}) { }) {
@ -1320,9 +1553,15 @@ function AclSection({
return ( return (
<div className="card"> <div className="card">
<h3>ACL policy for {network.name}</h3> <h3>ACL policy for {network.name}</h3>
<textarea value={aclText} onChange={(event) => onChange(event.target.value)} /> <textarea
value={aclText}
onChange={(event) => onChange(event.target.value)}
disabled={!canWrite}
/>
<div className="button-row"> <div className="button-row">
<button onClick={onSave}>Save policy</button> <button onClick={onSave} disabled={!canWrite}>
Save policy
</button>
</div> </div>
</div> </div>
) )
@ -1331,10 +1570,12 @@ function AclSection({
function KeyPolicySection({ function KeyPolicySection({
network, network,
policy, policy,
canWrite,
onSave, onSave,
}: { }: {
network: NetworkSummary | null network: NetworkSummary | null
policy: KeyPolicyResponse | null policy: KeyPolicyResponse | null
canWrite: boolean
onSave: (maxAge: number | null) => void onSave: (maxAge: number | null) => void
}) { }) {
const [maxAge, setMaxAge] = useState('') const [maxAge, setMaxAge] = useState('')
@ -1360,10 +1601,14 @@ function KeyPolicySection({
type="number" type="number"
value={maxAge} value={maxAge}
onChange={(event) => setMaxAge(event.target.value)} onChange={(event) => setMaxAge(event.target.value)}
disabled={!canWrite}
/> />
</label> </label>
<div className="button-row"> <div className="button-row">
<button onClick={() => onSave(maxAge ? Number(maxAge) : null)}> <button
onClick={() => onSave(maxAge ? Number(maxAge) : null)}
disabled={!canWrite}
>
Save policy Save policy
</button> </button>
</div> </div>
@ -1375,6 +1620,7 @@ function UsersSection({
users, users,
roles, roles,
selectedUser, selectedUser,
canWrite,
onSelect, onSelect,
onCreate, onCreate,
onUpdate, onUpdate,
@ -1385,6 +1631,7 @@ function UsersSection({
users: UserSummary[] users: UserSummary[]
roles: RoleSummary[] roles: RoleSummary[]
selectedUser: UserDetail | null selectedUser: UserDetail | null
canWrite: boolean
onSelect: (id: string) => void onSelect: (id: string) => void
onCreate: (payload: { onCreate: (payload: {
email: string email: string
@ -1446,6 +1693,7 @@ function UsersSection({
<form <form
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault() event.preventDefault()
if (!canWrite) return
onCreate({ onCreate({
email: createForm.email, email: createForm.email,
display_name: createForm.display_name || undefined, display_name: createForm.display_name || undefined,
@ -1519,7 +1767,8 @@ function UsersSection({
/> />
<span>Super admin</span> <span>Super admin</span>
</label> </label>
<button type="submit">Create user</button> <button type="submit" disabled={!canWrite}>Create user</button>
{!canWrite && <p className="muted">Read-only.</p>}
</form> </form>
</div> </div>
<div className="card"> <div className="card">
@ -1583,6 +1832,7 @@ function UsersSection({
<span>Super admin</span> <span>Super admin</span>
</label> </label>
<button <button
disabled={!canWrite}
onClick={() => onClick={() =>
onUpdate(selectedUser.user.id, { onUpdate(selectedUser.user.id, {
display_name: updateForm.display_name, display_name: updateForm.display_name,
@ -1604,6 +1854,7 @@ function UsersSection({
</label> </label>
<button <button
className="ghost" className="ghost"
disabled={!canWrite}
onClick={() => { onClick={() => {
if (!password) return if (!password) return
onSetPassword(selectedUser.user.id, password) onSetPassword(selectedUser.user.id, password)
@ -1632,6 +1883,7 @@ function UsersSection({
</div> </div>
<button <button
className="ghost" className="ghost"
disabled={!canWrite}
onClick={() => onClick={() =>
onDeleteMembership(selectedUser.user.id, membership.id) onDeleteMembership(selectedUser.user.id, membership.id)
} }
@ -1647,6 +1899,7 @@ function UsersSection({
<form <form
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault() event.preventDefault()
if (!canWrite) return
if (!membershipForm.role_id) return if (!membershipForm.role_id) return
onAddMembership(selectedUser.user.id, membershipForm) onAddMembership(selectedUser.user.id, membershipForm)
}} }}
@ -1697,7 +1950,7 @@ function UsersSection({
/> />
</label> </label>
</div> </div>
<button type="submit">Add membership</button> <button type="submit" disabled={!canWrite}>Add membership</button>
</form> </form>
</div> </div>
) : ( ) : (

View file

@ -35,6 +35,19 @@ export type UserSummary = {
updated_at: string; updated_at: string;
}; };
export type AuthUserSummary = {
id: string;
email: string;
display_name?: string | null;
super_admin: boolean;
};
export type AuthSession = {
user: AuthUserSummary;
permissions: string[];
console_access: boolean;
};
export type MembershipSummary = { export type MembershipSummary = {
id: string; id: string;
role_id: string; role_id: string;
@ -97,6 +110,9 @@ export type NodeInfo = {
machine_public_key: string; machine_public_key: string;
endpoints: string[]; endpoints: string[];
tags: string[]; tags: string[];
owner_user_id?: string | null;
owner_email?: string | null;
owner_is_admin?: boolean;
routes: RouteInfo[]; routes: RouteInfo[];
last_seen: number; last_seen: number;
approved: boolean; approved: boolean;
@ -113,6 +129,9 @@ export type EnrollmentToken = {
expires_at: number; expires_at: number;
uses_left: number; uses_left: number;
tags: string[]; tags: string[];
owner_user_id?: string | null;
owner_email?: string | null;
owner_is_admin?: boolean;
revoked_at?: number | null; revoked_at?: number | null;
}; };
@ -188,11 +207,11 @@ export type ProviderSummary = {
}; };
export async function getMe() { export async function getMe() {
return request<UserSummary>('/auth/me'); return request<AuthSession>('/auth/me');
} }
export async function login(email: string, password: string) { export async function login(email: string, password: string) {
return request<{ user: UserSummary }>('/auth/login', { return request<AuthSession>('/auth/login', {
method: 'POST', method: 'POST',
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password }),
}); });
@ -249,6 +268,8 @@ export async function listNetworks() {
export async function createNetwork(payload: { export async function createNetwork(payload: {
control_plane_id: string; control_plane_id: string;
name: string; name: string;
overlay_v4?: string;
overlay_v6?: string;
dns_domain?: string; dns_domain?: string;
requires_approval?: boolean; requires_approval?: boolean;
key_rotation_max_age_seconds?: number; key_rotation_max_age_seconds?: number;
@ -419,3 +440,23 @@ export async function listControlPlaneAudit(
`/audit/control-planes/${controlPlaneId}${query ? `?${query}` : ''}`, `/audit/control-planes/${controlPlaneId}${query ? `?${query}` : ''}`,
); );
} }
export async function deleteNetwork(id: string): Promise<void> {
const res = await fetch(`${API_BASE}/networks/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (!res.ok) {
throw new Error(`Failed to delete network: ${res.statusText}`);
}
}
export async function deleteControlPlane(id: string): Promise<void> {
const res = await fetch(`${API_BASE}/control-planes/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (!res.ok) {
throw new Error(`Failed to delete control plane: ${res.statusText}`);
}
}