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({