From 98eb7057a5518a9dbb9ada16309fda056c1a0f21 Mon Sep 17 00:00:00 2001
From: centra
Date: Sat, 14 Feb 2026 15:46:25 +0900
Subject: [PATCH] Implement user-bound join flows and add admin image build
pipeline
---
.dockerignore | 8 +
.forgejo/workflows/build-local-image.yml | 20 ++
Dockerfile | 34 ++
README.md | 32 ++
backend/migrations/001_init.sql | 13 +-
backend/src/api/auth.rs | 338 ++++++++++++++++--
backend/src/api/mod.rs | 2 +
backend/src/api/networks.rs | 48 ++-
backend/src/config.rs | 6 +
backend/src/control_plane.rs | 78 ++++-
backend/src/db.rs | 5 +
backend/src/main.rs | 25 +-
backend/src/permissions.rs | 32 ++
config.example.toml | 2 +
frontend/src/App.tsx | 425 ++++++++++++++++++-----
frontend/src/api.ts | 45 ++-
16 files changed, 1000 insertions(+), 113 deletions(-)
create mode 100644 .dockerignore
create mode 100644 .forgejo/workflows/build-local-image.yml
create mode 100644 Dockerfile
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..5d4fb2f
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,8 @@
+.git
+.gitignore
+target
+frontend/node_modules
+frontend/dist
+backend/target
+Dockerfile*
+.forgejo
diff --git a/.forgejo/workflows/build-local-image.yml b/.forgejo/workflows/build-local-image.yml
new file mode 100644
index 0000000..9cc6593
--- /dev/null
+++ b/.forgejo/workflows/build-local-image.yml
@@ -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
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..572b300
--- /dev/null
+++ b/Dockerfile
@@ -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"]
diff --git a/README.md b/README.md
index c910e42..a08aea3 100644
--- a/README.md
+++ b/README.md
@@ -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 plane’s `/v1/*` endpoints to manage networks and nodes.
diff --git a/backend/migrations/001_init.sql b/backend/migrations/001_init.sql
index 11427f2..884cb13 100644
--- a/backend/migrations/001_init.sql
+++ b/backend/migrations/001_init.sql
@@ -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());
diff --git a/backend/src/api/auth.rs b/backend/src/api/auth.rs
index f9e5a36..5b1c60e 100644
--- a/backend/src/api/auth.rs
+++ b/backend/src/api/auth.rs
@@ -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,
+ 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,
+ uses: Option,
+ tags: Option>,
+}
+
+#[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,
+ overlay_v4: Option,
+ overlay_v6: Option,
+ 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,
+ overlay_v4: Option,
+ overlay_v6: Option,
+ requires_approval: bool,
+ base_url: String,
+ admin_token: Option,
+}
+
pub fn router() -> Router {
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, 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,
+ jar: CookieJar,
+) -> Result, 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,
+ jar: CookieJar,
+) -> Result>, 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,
+ jar: CookieJar,
+ Json(req): Json,
+) -> Result, 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 {
+ 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 {
+ 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, 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 {
+ 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) -> Result {
+ 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) -> Result {
+ 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)
+}
diff --git a/backend/src/api/mod.rs b/backend/src/api/mod.rs
index 40ef6c2..467ec91 100644
--- a/backend/src/api/mod.rs
+++ b/backend/src/api/mod.rs
@@ -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 for AuthUser {
if user.disabled {
return Err(ApiError::Unauthorized);
}
+ ensure_permission_global(&state.pool, &user, PERM_CONSOLE_ACCESS).await?;
Ok(Self { user })
}
}
diff --git a/backend/src/api/networks.rs b/backend/src/api/networks.rs
index 58ac844..8fd65f5 100644
--- a/backend/src/api/networks.rs
+++ b/backend/src/api/networks.rs
@@ -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,
+ overlay_v6: Option,
dns_domain: Option,
requires_approval: Option,
key_rotation_max_age_seconds: Option,
@@ -86,7 +89,7 @@ struct NetworkAuditQuery {
pub fn router() -> Router {
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,
+ AuthUser { user }: AuthUser,
+ Path(id): Path,
+) -> Result {
+ 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,
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
diff --git a/backend/src/config.rs b/backend/src/config.rs
index da29d4b..bc20368 100644
--- a/backend/src/config.rs
+++ b/backend/src/config.rs
@@ -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
}
diff --git a/backend/src/control_plane.rs b/backend/src/control_plane.rs
index 092dc72..94daf92 100644
--- a/backend/src/control_plane.rs
+++ b/backend/src/control_plane.rs
@@ -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,
+ pub overlay_v6: Option,
pub dns_domain: Option,
pub requires_approval: Option,
pub key_rotation_max_age_seconds: Option,
@@ -236,6 +250,12 @@ pub struct EnrollmentToken {
pub expires_at: i64,
pub uses_left: u32,
pub tags: Vec,
+ #[serde(default)]
+ pub owner_user_id: Option,
+ #[serde(default)]
+ pub owner_email: Option,
+ #[serde(default)]
+ pub owner_is_admin: bool,
pub revoked_at: Option,
}
@@ -244,6 +264,12 @@ pub struct CreateTokenRequest {
pub ttl_seconds: u64,
pub uses: u32,
pub tags: Vec,
+ #[serde(default)]
+ pub owner_user_id: Option,
+ #[serde(default)]
+ pub owner_email: Option,
+ #[serde(default)]
+ pub owner_is_admin: Option,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -267,6 +293,12 @@ pub struct NodeInfo {
pub machine_public_key: String,
pub endpoints: Vec,
pub tags: Vec,
+ #[serde(default)]
+ pub owner_user_id: Option,
+ #[serde(default)]
+ pub owner_email: Option,
+ #[serde(default)]
+ pub owner_is_admin: bool,
pub routes: Vec,
pub last_seen: i64,
pub approved: bool,
@@ -364,3 +396,47 @@ pub struct AuditLogQuery {
pub node_id: Option,
pub limit: Option,
}
+
+#[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")
+ );
+ }
+}
diff --git a/backend/src/db.rs b/backend/src/db.rs
index 9ee7c2e..32457a7 100644
--- a/backend/src/db.rs
+++ b/backend/src/db.rs
@@ -7,3 +7,8 @@ pub async fn connect(config: &DatabaseConfig) -> Result {
.connect(&config.url)
.await
}
+
+pub async fn is_cockroach(pool: &PgPool) -> Result {
+ let version: String = sqlx::query_scalar("SELECT version()").fetch_one(pool).await?;
+ Ok(version.contains("CockroachDB"))
+}
diff --git a/backend/src/main.rs b/backend/src/main.rs
index 28c7ffb..226a383 100644
--- a/backend/src/main.rs
+++ b/backend/src/main.rs
@@ -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?;
diff --git a/backend/src/permissions.rs b/backend/src/permissions.rs
index 12772a3..0484a20 100644
--- a/backend/src/permissions.rs
+++ b/backend/src/permissions.rs
@@ -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, 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)
+}
diff --git a/config.example.toml b/config.example.toml
index 61599de..0015055 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -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
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 4b19b49..2bd4672 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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(null)
+ const [session, setSession] = useState(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
+ if (!session) {
+ return
+ }
+
+ if (!session.console_access) {
+ return (
+
+
+
Console access is not granted
+
+ This account is authenticated, but it does not have the
+ console:access permission.
+
+
+
+
+
+
+ )
}
return (
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([])
@@ -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('overview')
const [banner, setBanner] = useState(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(() => {
- refreshControlPlanes()
- refreshNetworks()
- refreshRoles()
- refreshUsers()
- }, [])
+ 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
- listNodes(selectedNetworkId)
- .then(setNodesResponse)
- .catch((err) => handleError(err, 'error'))
- getAcl(selectedNetworkId)
- .then((policy) => setAclText(JSON.stringify(policy, null, 2)))
- .catch(() => setAclText(''))
- getKeyPolicy(selectedNetworkId)
- .then(setKeyPolicy)
- .catch(() => setKeyPolicy(null))
- }, [selectedNetworkId])
+ 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))
+ } 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({