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.
- `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
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.
- `server.allowed_origins`: set when the UI is hosted separately (CORS + cookies).
- `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
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
('00000000-0000-0000-0000-000000000001', 'owner', 'Full 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)
VALUES
@ -124,6 +126,7 @@ VALUES
('00000000-0000-0000-0000-000000000001', 'nodes:read', now()),
('00000000-0000-0000-0000-000000000001', 'nodes: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:write', 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', 'roles:read', 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', 'networks:read', now()),
@ -140,6 +144,7 @@ VALUES
('00000000-0000-0000-0000-000000000002', 'nodes:read', now()),
('00000000-0000-0000-0000-000000000002', 'nodes: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:write', 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', 'users: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', 'networks:read', now()),
('00000000-0000-0000-0000-000000000003', 'nodes: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::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::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::response::Redirect;
use axum::routing::{get, post};
@ -16,6 +24,12 @@ use sqlx::{FromRow, PgPool};
use time::{Duration, OffsetDateTime};
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)]
struct LoginRequest {
email: String,
@ -25,6 +39,8 @@ struct LoginRequest {
#[derive(Debug, Serialize)]
struct LoginResponse {
user: UserSummary,
permissions: Vec<String>,
console_access: bool,
}
#[derive(Debug, Serialize)]
@ -61,11 +77,55 @@ struct OidcStateRow {
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> {
Router::new()
.route("/login", post(login))
.route("/logout", post(logout))
.route("/me", get(me))
.route("/join-networks", get(join_networks))
.route("/join-token", post(create_join_token))
.route("/providers", get(list_providers))
.route("/oidc/:provider/login", get(oidc_login))
.route("/oidc/:provider/callback", get(oidc_callback))
@ -109,17 +169,8 @@ async fn login(
let cookie = build_cookie(&state, token);
let jar = jar.add(cookie);
Ok((
jar,
Json(LoginResponse {
user: UserSummary {
id: user.id,
email: user.email,
display_name: user.display_name,
super_admin: user.super_admin,
},
}),
))
let response = auth_response(&state, user).await?;
Ok((jar, Json(response)))
}
async fn logout(
@ -136,12 +187,79 @@ async fn logout(
Ok((jar, Json(json!({ "ok": true }))))
}
async fn me(AuthUser { user }: AuthUser) -> Result<Json<UserSummary>, ApiError> {
Ok(Json(UserSummary {
id: user.id,
email: user.email,
display_name: user.display_name,
super_admin: user.super_admin,
async fn me(
State(state): State<AppState>,
jar: CookieJar,
) -> Result<Json<LoginResponse>, ApiError> {
let user = authenticated_user(&state, &jar).await?;
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)?;
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)
.await
@ -363,6 +481,24 @@ async fn ensure_oidc_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> {
let mut cookie = Cookie::build((SESSION_COOKIE, token))
.http_only(true)
@ -444,3 +580,165 @@ pub async fn ensure_bootstrap_admin(pool: &PgPool, config: &crate::config::Confi
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::auth::{session_user, SESSION_COOKIE};
use crate::models::User;
use crate::permissions::{ensure_permission_global, PERM_CONSOLE_ACCESS};
use axum::extract::FromRequestParts;
use axum::http::{request::Parts, StatusCode};
use axum::response::{IntoResponse, Response};
@ -70,6 +71,7 @@ impl FromRequestParts<AppState> for AuthUser {
if user.disabled {
return Err(ApiError::Unauthorized);
}
ensure_permission_global(&state.pool, &user, PERM_CONSOLE_ACCESS).await?;
Ok(Self { user })
}
}

View file

@ -13,6 +13,7 @@ use crate::permissions::{
PERM_NODES_WRITE, PERM_TOKENS_WRITE, PERM_AUDIT_READ,
};
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
@ -40,6 +41,8 @@ struct NetworkSummary {
struct CreateNetworkRequest {
control_plane_id: Uuid,
name: String,
overlay_v4: Option<String>,
overlay_v6: Option<String>,
dns_domain: Option<String>,
requires_approval: Option<bool>,
key_rotation_max_age_seconds: Option<u64>,
@ -86,7 +89,7 @@ struct NetworkAuditQuery {
pub fn router() -> Router<crate::app_state::AppState> {
Router::new()
.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/revoke", post(revoke_token))
.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(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
@ -183,6 +224,8 @@ async fn create_network(
let response = client
.create_network(CpCreateNetworkRequest {
name: req.name,
overlay_v4: req.overlay_v4.clone(),
overlay_v6: req.overlay_v6.clone(),
dns_domain: req.dns_domain.clone(),
requires_approval: req.requires_approval,
key_rotation_max_age_seconds: req.key_rotation_max_age_seconds,
@ -249,6 +292,9 @@ async fn create_token(
ttl_seconds: req.ttl_seconds,
uses: req.uses,
tags: req.tags,
owner_user_id: Some(user.id.to_string()),
owner_email: Some(user.email.clone()),
owner_is_admin: Some(true),
},
)
.await

View file

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

View file

@ -25,7 +25,7 @@ impl ControlPlaneClient {
fn auth_header(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if let Some(token) = &self.admin_token {
req.header("X-Lightscale-Admin-Token", token)
req.bearer_auth(token)
} else {
req
}
@ -199,11 +199,25 @@ impl ControlPlaneClient {
.map_err(|err| anyhow!("audit log failed: {}", err))?;
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)]
pub struct CreateNetworkRequest {
pub name: String,
pub overlay_v4: Option<String>,
pub overlay_v6: Option<String>,
pub dns_domain: Option<String>,
pub requires_approval: Option<bool>,
pub key_rotation_max_age_seconds: Option<u64>,
@ -236,6 +250,12 @@ pub struct EnrollmentToken {
pub expires_at: i64,
pub uses_left: u32,
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>,
}
@ -244,6 +264,12 @@ pub struct CreateTokenRequest {
pub ttl_seconds: u64,
pub uses: u32,
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)]
@ -267,6 +293,12 @@ pub struct NodeInfo {
pub machine_public_key: String,
pub endpoints: 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 last_seen: i64,
pub approved: bool,
@ -364,3 +396,47 @@ pub struct AuditLogQuery {
pub node_id: Option<String>,
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)
.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 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?;

View file

@ -5,11 +5,13 @@ use sqlx::PgPool;
pub const PERM_CONTROL_PLANES_READ: &str = "control_planes:read";
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_WRITE: &str = "networks:write";
pub const PERM_NODES_READ: &str = "nodes:read";
pub const PERM_NODES_WRITE: &str = "nodes: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_WRITE: &str = "acl:write";
pub const PERM_KEY_POLICY_READ: &str = "key_policy:read";
@ -47,3 +49,33 @@ pub async fn ensure_permission(
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]
url = "postgresql://root@localhost:26257/lightscale_admin?sslmode=disable"
max_connections = 20
# Optional override. CockroachDB is auto-detected and migration locking is disabled automatically.
disable_migration_locking = true
[auth]
session_ttl_minutes = 720

View file

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

View file

@ -35,6 +35,19 @@ export type UserSummary = {
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 = {
id: string;
role_id: string;
@ -97,6 +110,9 @@ export type NodeInfo = {
machine_public_key: string;
endpoints: string[];
tags: string[];
owner_user_id?: string | null;
owner_email?: string | null;
owner_is_admin?: boolean;
routes: RouteInfo[];
last_seen: number;
approved: boolean;
@ -113,6 +129,9 @@ export type EnrollmentToken = {
expires_at: number;
uses_left: number;
tags: string[];
owner_user_id?: string | null;
owner_email?: string | null;
owner_is_admin?: boolean;
revoked_at?: number | null;
};
@ -188,11 +207,11 @@ export type ProviderSummary = {
};
export async function getMe() {
return request<UserSummary>('/auth/me');
return request<AuthSession>('/auth/me');
}
export async function login(email: string, password: string) {
return request<{ user: UserSummary }>('/auth/login', {
return request<AuthSession>('/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
@ -249,6 +268,8 @@ export async function listNetworks() {
export async function createNetwork(payload: {
control_plane_id: string;
name: string;
overlay_v4?: string;
overlay_v6?: string;
dns_domain?: string;
requires_approval?: boolean;
key_rotation_max_age_seconds?: number;
@ -419,3 +440,23 @@ export async function listControlPlaneAudit(
`/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}`);
}
}