Initial commit

This commit is contained in:
Soma Nakamura 2026-02-13 17:07:42 +09:00
commit 6ae8b6e898
Signed by: centra
GPG key ID: 0C09689D20B25ACA
42 changed files with 13774 additions and 0 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
/target
/backend/target
/frontend/node_modules
/frontend/dist
/config.toml
/.env
.DS_Store

3980
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

7
Cargo.toml Normal file
View file

@ -0,0 +1,7 @@
[workspace]
members = ["backend"]
resolver = "2"
[profile.release]
lto = true
codegen-units = 1

53
README.md Normal file
View file

@ -0,0 +1,53 @@
# lightscale-admin
A thin admin control plane for Lightscale. It stores operator metadata in CockroachDB and calls one or more Lightscale control plane APIs to manage networks, nodes, tokens, ACLs, key policies, and audit streams. The UI is a SPA (no SSR) and can be served by the backend or hosted separately.
## Layout
- `backend/`: Rust (Axum) API server, `/admin/api` namespace.
- `frontend/`: Vite React SPA.
## Quick start
1) Start CockroachDB (single node for local dev):
```bash
cd /home/centra/dev/lightscale-admin
docker compose up -d
```
2) Create a config:
```bash
cp config.example.toml config.toml
```
3) Build the UI (optional if you run the Vite dev server):
```bash
cd frontend
npm install
npm run build
```
4) Run the backend from the repo root:
```bash
cargo run -p lightscale-admin-server
```
The admin UI will be served from `server.static_dir` if configured. Otherwise, run the Vite dev server and set `server.allowed_origins` to `http://localhost:5173`.
## Configuration
Configuration loads from `config.toml` and `LS_ADMIN__` environment variables (nested keys separated by `__`). See `config.example.toml`.
Key settings:
- `server.base_url`: used for OIDC redirect URLs.
- `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`).
## 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.
## Multi-region notes
CockroachDB allows multi-region deployments. For production, run a multi-node cluster and point `database.url` at the load-balanced SQL endpoint. The admin API itself is stateless and can be deployed across regions.

30
backend/Cargo.toml Normal file
View file

@ -0,0 +1,30 @@
[package]
name = "lightscale-admin-server"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1"
argon2 = "0.5"
axum = { version = "0.7", features = ["macros", "json"] }
axum-extra = { version = "0.9", features = ["cookie"] }
config = "0.14"
hex = "0.4"
openidconnect = { version = "3", default-features = false, features = ["reqwest", "rustls-tls"] }
rand = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "uuid", "time", "json", "migrate"] }
thiserror = "1"
time = { version = "0.3", features = ["serde", "macros"] }
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.5", features = ["cors", "fs", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
url = "2"
uuid = { version = "1", features = ["v4", "serde"] }
[dev-dependencies]
http = "1"

View file

@ -0,0 +1,155 @@
CREATE TABLE users (
id UUID PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
display_name TEXT,
password_hash TEXT,
disabled BOOL NOT NULL DEFAULT false,
super_admin BOOL NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE roles (
id UUID PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE role_permissions (
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
permission TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (role_id, permission)
);
CREATE TABLE memberships (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
scope_type TEXT NOT NULL,
scope_id TEXT NOT NULL,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL,
UNIQUE (user_id, scope_type, scope_id)
);
CREATE TABLE sessions (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
last_seen_at TIMESTAMPTZ NOT NULL,
user_agent TEXT,
ip TEXT
);
CREATE INDEX sessions_user_id_idx ON sessions (user_id);
CREATE INDEX sessions_expires_at_idx ON sessions (expires_at);
CREATE TABLE control_planes (
id UUID PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
base_url TEXT NOT NULL,
admin_token TEXT,
region TEXT,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE networks (
id UUID PRIMARY KEY,
control_plane_id UUID NOT NULL REFERENCES control_planes(id) ON DELETE CASCADE,
network_id TEXT NOT NULL,
name TEXT NOT NULL,
dns_domain TEXT,
overlay_v4 TEXT,
overlay_v6 TEXT,
requires_approval BOOL NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
UNIQUE (control_plane_id, network_id)
);
CREATE INDEX networks_control_plane_idx ON networks (control_plane_id);
CREATE TABLE audit_log (
id UUID PRIMARY KEY,
actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
target_type TEXT,
target_id TEXT,
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX audit_log_created_at_idx ON audit_log (created_at);
CREATE TABLE user_providers (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
subject TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
UNIQUE (provider, subject),
UNIQUE (user_id, provider)
);
CREATE TABLE oidc_states (
id UUID PRIMARY KEY,
provider_id TEXT NOT NULL,
state TEXT NOT NULL,
nonce TEXT NOT NULL,
verifier TEXT NOT NULL,
redirect TEXT,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
UNIQUE (provider_id, state)
);
CREATE INDEX oidc_states_expires_at_idx ON oidc_states (expires_at);
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());
INSERT INTO role_permissions (role_id, permission, created_at)
VALUES
('00000000-0000-0000-0000-000000000001', 'control_planes:read', now()),
('00000000-0000-0000-0000-000000000001', 'control_planes:write', now()),
('00000000-0000-0000-0000-000000000001', 'networks:read', now()),
('00000000-0000-0000-0000-000000000001', 'networks:write', now()),
('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', 'acl:read', now()),
('00000000-0000-0000-0000-000000000001', 'acl:write', now()),
('00000000-0000-0000-0000-000000000001', 'key_policy:read', now()),
('00000000-0000-0000-0000-000000000001', 'key_policy:write', now()),
('00000000-0000-0000-0000-000000000001', 'audit:read', now()),
('00000000-0000-0000-0000-000000000001', 'users:read', now()),
('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-000000000002', 'control_planes:read', now()),
('00000000-0000-0000-0000-000000000002', 'networks:read', now()),
('00000000-0000-0000-0000-000000000002', 'networks:write', now()),
('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', 'acl:read', now()),
('00000000-0000-0000-0000-000000000002', 'acl:write', now()),
('00000000-0000-0000-0000-000000000002', 'key_policy:read', now()),
('00000000-0000-0000-0000-000000000002', 'key_policy:write', now()),
('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-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());

143
backend/src/api/audit.rs Normal file
View file

@ -0,0 +1,143 @@
use crate::api::{ApiError, AuthUser};
use crate::control_plane::AuditLogResponse as ControlPlaneAuditLog;
use crate::models::ControlPlane;
use crate::permissions::{ensure_permission_global, PERM_AUDIT_READ};
use axum::extract::{Path, Query, State};
use axum::routing::get;
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use time::OffsetDateTime;
use uuid::Uuid;
#[derive(Debug, Deserialize)]
struct AdminAuditQuery {
actor_user_id: Option<Uuid>,
action: Option<String>,
limit: Option<i64>,
}
#[derive(Debug, Deserialize)]
struct ControlPlaneAuditQuery {
network_id: Option<String>,
node_id: Option<String>,
limit: Option<usize>,
}
#[derive(Debug, Serialize)]
struct AdminAuditEntry {
id: Uuid,
actor_user_id: Option<Uuid>,
actor_email: Option<String>,
action: String,
target_type: Option<String>,
target_id: Option<String>,
metadata: Option<serde_json::Value>,
created_at: OffsetDateTime,
}
pub fn router() -> Router<crate::app_state::AppState> {
Router::new()
.route("/", get(list_admin_audit))
.route("/control-planes/:id", get(control_plane_audit))
}
async fn list_admin_audit(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Query(query): Query<AdminAuditQuery>,
) -> Result<Json<Vec<AdminAuditEntry>>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_AUDIT_READ).await?;
let limit = query.limit.unwrap_or(200).max(1).min(1000) as i64;
let rows = sqlx::query_as::<_, AdminAuditRow>(
r#"
SELECT a.id,
a.actor_user_id,
u.email AS actor_email,
a.action,
a.target_type,
a.target_id,
a.metadata,
a.created_at
FROM audit_log a
LEFT JOIN users u ON u.id = a.actor_user_id
WHERE ($1::uuid IS NULL OR a.actor_user_id = $1)
AND ($2::text IS NULL OR a.action = $2)
ORDER BY a.created_at DESC
LIMIT $3
"#,
)
.bind(query.actor_user_id)
.bind(query.action)
.bind(limit)
.fetch_all(&state.pool)
.await
.map_err(|_| ApiError::Internal)?;
Ok(Json(
rows.into_iter()
.map(|row| AdminAuditEntry {
id: row.id,
actor_user_id: row.actor_user_id,
actor_email: row.actor_email,
action: row.action,
target_type: row.target_type,
target_id: row.target_id,
metadata: row.metadata,
created_at: row.created_at,
})
.collect(),
))
}
async fn control_plane_audit(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path(control_plane_id): Path<Uuid>,
Query(query): Query<ControlPlaneAuditQuery>,
) -> Result<Json<ControlPlaneAuditLog>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_AUDIT_READ).await?;
let control_plane = fetch_control_plane(&state.pool, control_plane_id).await?;
let client = crate::control_plane::ControlPlaneClient::new(
control_plane.base_url,
control_plane.admin_token,
);
let response = client
.audit_log(crate::control_plane::AuditLogQuery {
network_id: query.network_id,
node_id: query.node_id,
limit: query.limit,
})
.await
.map_err(|_| ApiError::BadRequest("control plane request failed".to_string()))?;
Ok(Json(response))
}
#[derive(sqlx::FromRow)]
struct AdminAuditRow {
id: Uuid,
actor_user_id: Option<Uuid>,
actor_email: Option<String>,
action: String,
target_type: Option<String>,
target_id: Option<String>,
metadata: Option<serde_json::Value>,
created_at: OffsetDateTime,
}
async fn fetch_control_plane(pool: &PgPool, id: Uuid) -> Result<ControlPlane, ApiError> {
sqlx::query_as::<_, ControlPlane>(
r#"
SELECT id, name, base_url, admin_token, region, created_at, updated_at
FROM control_planes
WHERE id = $1
"#,
)
.bind(id)
.fetch_optional(pool)
.await
.map_err(|_| ApiError::Internal)?
.ok_or(ApiError::NotFound)
}

446
backend/src/api/auth.rs Normal file
View file

@ -0,0 +1,446 @@
use crate::api::{ApiError, AuthUser};
use crate::app_state::AppState;
use crate::auth::{create_session, delete_session, hash_password, verify_password, SESSION_COOKIE};
use crate::models::User;
use crate::oidc::{build_auth_request, exchange_code};
use axum::extract::{Path, Query, State};
use axum::response::Redirect;
use axum::routing::{get, post};
use axum::Json;
use axum::Router;
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use openidconnect::{Nonce, TokenResponse};
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::{FromRow, PgPool};
use time::{Duration, OffsetDateTime};
use uuid::Uuid;
#[derive(Debug, Deserialize)]
struct LoginRequest {
email: String,
password: String,
}
#[derive(Debug, Serialize)]
struct LoginResponse {
user: UserSummary,
}
#[derive(Debug, Serialize)]
struct UserSummary {
id: Uuid,
email: String,
display_name: Option<String>,
super_admin: bool,
}
#[derive(Debug, Serialize)]
struct ProviderSummary {
id: String,
name: String,
}
#[derive(Debug, Deserialize)]
struct OidcCallbackQuery {
code: String,
state: String,
}
#[derive(Debug, Deserialize)]
struct OidcLoginQuery {
next: Option<String>,
}
#[derive(Debug, FromRow)]
struct OidcStateRow {
id: Uuid,
nonce: String,
verifier: String,
redirect: Option<String>,
expires_at: OffsetDateTime,
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/login", post(login))
.route("/logout", post(logout))
.route("/me", get(me))
.route("/providers", get(list_providers))
.route("/oidc/:provider/login", get(oidc_login))
.route("/oidc/:provider/callback", get(oidc_callback))
}
async fn login(
State(state): State<AppState>,
jar: CookieJar,
Json(req): Json<LoginRequest>,
) -> Result<(CookieJar, Json<LoginResponse>), ApiError> {
let user = sqlx::query_as::<_, User>(
"SELECT id, email, display_name, password_hash, disabled, super_admin, created_at, updated_at FROM users WHERE email = $1",
)
.bind(req.email.to_lowercase())
.fetch_optional(&state.pool)
.await
.map_err(|_| ApiError::Internal)?
.ok_or(ApiError::Unauthorized)?;
if user.disabled {
return Err(ApiError::Unauthorized);
}
let hash = user.password_hash.clone().ok_or(ApiError::Unauthorized)?;
let ok = verify_password(&hash, &req.password).map_err(|_| ApiError::Internal)?;
if !ok {
return Err(ApiError::Unauthorized);
}
let ttl = Duration::minutes(state.config.auth.session_ttl_minutes);
let token = create_session(
&state.pool,
user.id,
ttl,
None,
None,
)
.await
.map_err(|_| ApiError::Internal)?;
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,
},
}),
))
}
async fn logout(
State(state): State<AppState>,
jar: CookieJar,
) -> Result<(CookieJar, Json<serde_json::Value>), ApiError> {
let Some(cookie) = jar.get(SESSION_COOKIE) else {
return Ok((jar, Json(json!({ "ok": true }))));
};
delete_session(&state.pool, cookie.value())
.await
.map_err(|_| ApiError::Internal)?;
let jar = jar.remove(clear_cookie(&state));
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 list_providers(State(state): State<AppState>) -> Result<Json<Vec<ProviderSummary>>, ApiError> {
let providers = state
.oidc
.values()
.map(|provider| ProviderSummary {
id: provider.id.clone(),
name: provider.name.clone(),
})
.collect::<Vec<_>>();
Ok(Json(providers))
}
async fn oidc_login(
State(state): State<AppState>,
Path(provider_id): Path<String>,
Query(query): Query<OidcLoginQuery>,
) -> Result<Redirect, ApiError> {
let provider = state
.oidc
.get(&provider_id)
.ok_or(ApiError::NotFound)?;
let auth = build_auth_request(provider).map_err(|_| ApiError::Internal)?;
let now = OffsetDateTime::now_utc();
let expires_at = now + Duration::minutes(10);
sqlx::query(
"INSERT INTO oidc_states (id, provider_id, state, nonce, verifier, redirect, expires_at, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
)
.bind(Uuid::new_v4())
.bind(&provider_id)
.bind(&auth.state)
.bind(&auth.nonce)
.bind(&auth.verifier)
.bind(query.next)
.bind(expires_at)
.bind(now)
.execute(&state.pool)
.await
.map_err(|_| ApiError::Internal)?;
Ok(Redirect::temporary(&auth.url))
}
async fn oidc_callback(
State(state): State<AppState>,
Path(provider_id): Path<String>,
Query(query): Query<OidcCallbackQuery>,
jar: CookieJar,
) -> Result<(CookieJar, Redirect), ApiError> {
let provider = state
.oidc
.get(&provider_id)
.ok_or(ApiError::NotFound)?
.clone();
let record = sqlx::query_as::<_, OidcStateRow>(
r#"
SELECT id, nonce, verifier, redirect, expires_at
FROM oidc_states
WHERE provider_id = $1 AND state = $2
"#,
)
.bind(&provider_id)
.bind(&query.state)
.fetch_optional(&state.pool)
.await
.map_err(|_| ApiError::Internal)?
.ok_or(ApiError::Unauthorized)?;
if record.expires_at <= OffsetDateTime::now_utc() {
return Err(ApiError::Unauthorized);
}
sqlx::query("DELETE FROM oidc_states WHERE id = $1")
.bind(record.id)
.execute(&state.pool)
.await
.map_err(|_| ApiError::Internal)?;
let token = exchange_code(&provider, query.code, record.verifier)
.await
.map_err(|_| ApiError::Unauthorized)?;
let id_token = token.id_token().ok_or(ApiError::Unauthorized)?;
let claims = id_token
.claims(&provider.client.id_token_verifier(), &Nonce::new(record.nonce))
.map_err(|_| ApiError::Unauthorized)?;
let subject = claims.subject().as_str().to_string();
let email = claims.email().map(|e| e.as_str().to_string());
let display_name = claims.name().and_then(|n| n.get(None)).map(|n| n.to_string());
let user = ensure_oidc_user(
&state.pool,
&provider_id,
&subject,
email.clone(),
display_name.clone(),
&state.config,
)
.await?;
let ttl = Duration::minutes(state.config.auth.session_ttl_minutes);
let session_token = create_session(&state.pool, user.id, ttl, None, None)
.await
.map_err(|_| ApiError::Internal)?;
let cookie = build_cookie(&state, session_token);
let jar = jar.add(cookie);
let redirect = record.redirect.unwrap_or_else(|| "/".to_string());
Ok((jar, Redirect::temporary(&redirect)))
}
async fn ensure_oidc_user(
pool: &PgPool,
provider_id: &str,
subject: &str,
email: Option<String>,
display_name: Option<String>,
config: &crate::config::Config,
) -> Result<User, ApiError> {
if let Some(user) = sqlx::query_as::<_, User>(
r#"
SELECT u.id, u.email, u.display_name, u.password_hash, u.disabled, u.super_admin, u.created_at, u.updated_at
FROM users u
JOIN user_providers p ON p.user_id = u.id
WHERE p.provider = $1 AND p.subject = $2
"#,
)
.bind(provider_id)
.bind(subject)
.fetch_optional(pool)
.await
.map_err(|_| ApiError::Internal)?
{
return Ok(user);
}
let mut tx = pool.begin().await.map_err(|_| ApiError::Internal)?;
let maybe_user = if let Some(email) = &email {
sqlx::query_as::<_, User>(
"SELECT id, email, display_name, password_hash, disabled, super_admin, created_at, updated_at FROM users WHERE email = $1",
)
.bind(email.to_lowercase())
.fetch_optional(&mut *tx)
.await
.map_err(|_| ApiError::Internal)?
} else {
None
};
let user = if let Some(user) = maybe_user {
user
} else {
if !config.auth.allow_user_signup && config.auth.bootstrap_admin_email.as_deref() != email.as_deref() {
return Err(ApiError::Forbidden);
}
let now = OffsetDateTime::now_utc();
let user_id = Uuid::new_v4();
let super_admin = email
.as_deref()
.map(|e| Some(e) == config.auth.bootstrap_admin_email.as_deref())
.unwrap_or(false);
let row = sqlx::query_as::<_, User>(
r#"
INSERT INTO users (id, email, display_name, password_hash, disabled, super_admin, created_at, updated_at)
VALUES ($1, $2, $3, NULL, false, $4, $5, $5)
RETURNING id, email, display_name, password_hash, disabled, super_admin, created_at, updated_at
"#,
)
.bind(user_id)
.bind(email.clone().unwrap_or_else(|| format!("{}@unknown", subject)))
.bind(display_name.clone())
.bind(super_admin)
.bind(now)
.fetch_one(&mut *tx)
.await
.map_err(|_| ApiError::Internal)?;
let role_id = sqlx::query_scalar::<_, Uuid>(
"SELECT id FROM roles WHERE name = 'viewer'",
)
.fetch_one(&mut *tx)
.await
.map_err(|_| ApiError::Internal)?;
sqlx::query(
"INSERT INTO memberships (id, user_id, scope_type, scope_id, role_id, created_at) VALUES ($1, $2, 'global', 'global', $3, $4)",
)
.bind(Uuid::new_v4())
.bind(row.id)
.bind(role_id)
.bind(now)
.execute(&mut *tx)
.await
.map_err(|_| ApiError::Internal)?;
row
};
sqlx::query(
"INSERT INTO user_providers (id, user_id, provider, subject, created_at) VALUES ($1, $2, $3, $4, $5)",
)
.bind(Uuid::new_v4())
.bind(user.id)
.bind(provider_id)
.bind(subject)
.bind(OffsetDateTime::now_utc())
.execute(&mut *tx)
.await
.map_err(|_| ApiError::Internal)?;
tx.commit().await.map_err(|_| ApiError::Internal)?;
Ok(user)
}
fn build_cookie(state: &AppState, token: String) -> Cookie<'static> {
let mut cookie = Cookie::build((SESSION_COOKIE, token))
.http_only(true)
.path("/")
.same_site(SameSite::Lax)
.secure(state.config.auth.cookie_secure)
.build();
if let Some(domain) = &state.config.auth.cookie_domain {
cookie.set_domain(domain.clone());
}
cookie
}
fn clear_cookie(state: &AppState) -> Cookie<'static> {
let mut cookie = Cookie::build((SESSION_COOKIE, ""))
.http_only(true)
.path("/")
.same_site(SameSite::Lax)
.secure(state.config.auth.cookie_secure)
.build();
cookie.make_removal();
if let Some(domain) = &state.config.auth.cookie_domain {
cookie.set_domain(domain.clone());
}
cookie
}
pub async fn ensure_bootstrap_admin(pool: &PgPool, config: &crate::config::Config) -> Result<(), ApiError> {
let Some(email) = config.auth.bootstrap_admin_email.as_ref() else {
return Ok(());
};
let Some(password) = config.auth.bootstrap_admin_password.as_ref() else {
return Ok(());
};
let existing = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM users")
.fetch_one(pool)
.await
.map_err(|_| ApiError::Internal)?;
if existing > 0 {
return Ok(());
}
let now = OffsetDateTime::now_utc();
let hash = hash_password(password).map_err(|_| ApiError::Internal)?;
let user = sqlx::query_as::<_, User>(
r#"
INSERT INTO users (id, email, display_name, password_hash, disabled, super_admin, created_at, updated_at)
VALUES ($1, $2, $3, $4, false, true, $5, $5)
RETURNING id, email, display_name, password_hash, disabled, super_admin, created_at, updated_at
"#,
)
.bind(Uuid::new_v4())
.bind(email.to_lowercase())
.bind(Some("Bootstrap Admin".to_string()))
.bind(hash)
.bind(now)
.fetch_one(pool)
.await
.map_err(|_| ApiError::Internal)?;
let role_id = sqlx::query_scalar::<_, Uuid>(
"SELECT id FROM roles WHERE name = 'owner'",
)
.fetch_one(pool)
.await
.map_err(|_| ApiError::Internal)?;
sqlx::query(
"INSERT INTO memberships (id, user_id, scope_type, scope_id, role_id, created_at) VALUES ($1, $2, 'global', 'global', $3, $4)",
)
.bind(Uuid::new_v4())
.bind(user.id)
.bind(role_id)
.bind(now)
.execute(pool)
.await
.map_err(|_| ApiError::Internal)?;
Ok(())
}

View file

@ -0,0 +1,293 @@
use crate::api::{ApiError, AuthUser};
use crate::audit_log;
use crate::models::ControlPlane;
use crate::permissions::{
ensure_permission_global, PERM_CONTROL_PLANES_READ, PERM_CONTROL_PLANES_WRITE,
};
use axum::extract::{Path, State};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use time::OffsetDateTime;
use uuid::Uuid;
#[derive(Debug, Serialize)]
struct ControlPlaneSummary {
id: Uuid,
name: String,
base_url: String,
region: Option<String>,
has_admin_token: bool,
created_at: OffsetDateTime,
updated_at: OffsetDateTime,
}
#[derive(Debug, Deserialize)]
struct CreateControlPlaneRequest {
name: String,
base_url: String,
admin_token: Option<String>,
region: Option<String>,
}
#[derive(Debug, Deserialize)]
struct UpdateControlPlaneRequest {
name: Option<String>,
base_url: Option<String>,
admin_token: Option<String>,
clear_admin_token: Option<bool>,
region: Option<String>,
}
#[derive(Debug, Serialize)]
struct VerifyResponse {
ok: bool,
status: Option<u16>,
body: Option<String>,
}
pub fn router() -> Router<crate::app_state::AppState> {
Router::new()
.route("/", get(list_control_planes).post(create_control_plane))
.route(
"/:id",
get(get_control_plane)
.put(update_control_plane)
.delete(delete_control_plane),
)
.route("/:id/verify", post(verify_control_plane))
}
async fn list_control_planes(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
) -> Result<Json<Vec<ControlPlaneSummary>>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_CONTROL_PLANES_READ).await?;
let rows = sqlx::query_as::<_, ControlPlane>(
r#"
SELECT id, name, base_url, admin_token, region, created_at, updated_at
FROM control_planes
ORDER BY name
"#,
)
.fetch_all(&state.pool)
.await
.map_err(|_| ApiError::Internal)?;
let summary = rows
.into_iter()
.map(|row| ControlPlaneSummary {
id: row.id,
name: row.name,
base_url: row.base_url,
region: row.region,
has_admin_token: row.admin_token.is_some(),
created_at: row.created_at,
updated_at: row.updated_at,
})
.collect();
Ok(Json(summary))
}
async fn create_control_plane(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Json(req): Json<CreateControlPlaneRequest>,
) -> Result<Json<ControlPlaneSummary>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_CONTROL_PLANES_WRITE).await?;
let now = OffsetDateTime::now_utc();
let base_url = req.base_url.trim_end_matches('/').to_string();
let record = sqlx::query_as::<_, ControlPlane>(
r#"
INSERT INTO control_planes (id, name, base_url, admin_token, region, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $6)
RETURNING id, name, base_url, admin_token, region, created_at, updated_at
"#,
)
.bind(Uuid::new_v4())
.bind(req.name)
.bind(base_url)
.bind(req.admin_token)
.bind(req.region)
.bind(now)
.fetch_one(&state.pool)
.await
.map_err(|_| ApiError::Internal)?;
audit_log::record(
&state.pool,
Some(user.id),
"control_plane.create",
Some("control_plane"),
Some(&record.id.to_string()),
None,
)
.await?;
Ok(Json(ControlPlaneSummary {
id: record.id,
name: record.name,
base_url: record.base_url,
region: record.region,
has_admin_token: record.admin_token.is_some(),
created_at: record.created_at,
updated_at: record.updated_at,
}))
}
async fn get_control_plane(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path(id): Path<Uuid>,
) -> Result<Json<ControlPlaneSummary>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_CONTROL_PLANES_READ).await?;
let record = fetch_control_plane(&state.pool, id).await?;
Ok(Json(ControlPlaneSummary {
id: record.id,
name: record.name,
base_url: record.base_url,
region: record.region,
has_admin_token: record.admin_token.is_some(),
created_at: record.created_at,
updated_at: record.updated_at,
}))
}
async fn update_control_plane(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path(id): Path<Uuid>,
Json(req): Json<UpdateControlPlaneRequest>,
) -> Result<Json<ControlPlaneSummary>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_CONTROL_PLANES_WRITE).await?;
let existing = fetch_control_plane(&state.pool, id).await?;
let name = req.name.unwrap_or(existing.name);
let base_url = req
.base_url
.map(|value| value.trim_end_matches('/').to_string())
.unwrap_or(existing.base_url);
let admin_token = if req.clear_admin_token.unwrap_or(false) {
None
} else {
req.admin_token.or(existing.admin_token)
};
let region = req.region.or(existing.region);
let now = OffsetDateTime::now_utc();
let record = sqlx::query_as::<_, ControlPlane>(
r#"
UPDATE control_planes
SET name = $2, base_url = $3, admin_token = $4, region = $5, updated_at = $6
WHERE id = $1
RETURNING id, name, base_url, admin_token, region, created_at, updated_at
"#,
)
.bind(id)
.bind(name)
.bind(base_url)
.bind(admin_token)
.bind(region)
.bind(now)
.fetch_one(&state.pool)
.await
.map_err(|_| ApiError::Internal)?;
audit_log::record(
&state.pool,
Some(user.id),
"control_plane.update",
Some("control_plane"),
Some(&record.id.to_string()),
None,
)
.await?;
Ok(Json(ControlPlaneSummary {
id: record.id,
name: record.name,
base_url: record.base_url,
region: record.region,
has_admin_token: record.admin_token.is_some(),
created_at: record.created_at,
updated_at: record.updated_at,
}))
}
async fn delete_control_plane(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_CONTROL_PLANES_WRITE).await?;
let record = fetch_control_plane(&state.pool, id).await?;
sqlx::query("DELETE FROM control_planes WHERE id = $1")
.bind(id)
.execute(&state.pool)
.await
.map_err(|_| ApiError::Internal)?;
audit_log::record(
&state.pool,
Some(user.id),
"control_plane.delete",
Some("control_plane"),
Some(&record.id.to_string()),
None,
)
.await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
async fn verify_control_plane(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path(id): Path<Uuid>,
) -> Result<Json<VerifyResponse>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_CONTROL_PLANES_READ).await?;
let record = fetch_control_plane(&state.pool, id).await?;
let url = format!("{}/healthz", record.base_url.trim_end_matches('/'));
let client = reqwest::Client::new();
let response = client.get(url).send().await;
let mut out = VerifyResponse {
ok: false,
status: None,
body: None,
};
match response {
Ok(resp) => {
out.status = Some(resp.status().as_u16());
out.ok = resp.status().is_success();
if let Ok(text) = resp.text().await {
out.body = Some(text);
}
}
Err(_) => {
out.ok = false;
}
}
Ok(Json(out))
}
async fn fetch_control_plane(pool: &PgPool, id: Uuid) -> Result<ControlPlane, ApiError> {
sqlx::query_as::<_, ControlPlane>(
r#"
SELECT id, name, base_url, admin_token, region, created_at, updated_at
FROM control_planes
WHERE id = $1
"#,
)
.bind(id)
.fetch_optional(pool)
.await
.map_err(|_| ApiError::Internal)?
.ok_or(ApiError::NotFound)
}

85
backend/src/api/mod.rs Normal file
View file

@ -0,0 +1,85 @@
pub mod auth;
pub mod control_planes;
pub mod networks;
pub mod users;
pub mod audit;
use crate::app_state::AppState;
use crate::auth::{session_user, SESSION_COOKIE};
use crate::models::User;
use axum::extract::FromRequestParts;
use axum::http::{request::Parts, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use axum::{routing::get, Router};
use axum_extra::extract::cookie::{Cookie, CookieJar};
use serde_json::json;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ApiError {
#[error("unauthorized")]
Unauthorized,
#[error("forbidden")]
Forbidden,
#[error("not found")]
NotFound,
#[error("bad request: {0}")]
BadRequest(String),
#[error("internal error")]
Internal,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = match self {
ApiError::Unauthorized => StatusCode::UNAUTHORIZED,
ApiError::Forbidden => StatusCode::FORBIDDEN,
ApiError::NotFound => StatusCode::NOT_FOUND,
ApiError::BadRequest(_) => StatusCode::BAD_REQUEST,
ApiError::Internal => StatusCode::INTERNAL_SERVER_ERROR,
};
let message = match &self {
ApiError::BadRequest(msg) => msg.clone(),
_ => self.to_string(),
};
(status, Json(json!({ "error": message }))).into_response()
}
}
#[derive(Clone, Debug)]
pub struct AuthUser {
pub user: User,
}
#[axum::async_trait]
impl FromRequestParts<AppState> for AuthUser {
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
let jar = CookieJar::from_headers(&parts.headers);
let token = jar
.get(SESSION_COOKIE)
.map(Cookie::value)
.map(|v| v.to_string())
.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(Self { user })
}
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/healthz", get(|| async { "ok" }))
.nest("/auth", auth::router())
.nest("/users", users::router())
.nest("/control-planes", control_planes::router())
.nest("/networks", networks::router())
.nest("/audit", audit::router())
}

631
backend/src/api/networks.rs Normal file
View file

@ -0,0 +1,631 @@
use crate::api::{ApiError, AuthUser};
use crate::audit_log;
use crate::control_plane::{
AdminNodesResponse, CreateNetworkRequest as CpCreateNetworkRequest,
CreateTokenRequest as CpCreateTokenRequest, CreateTokenResponse, EnrollmentToken,
KeyHistoryResponse, KeyPolicyResponse, KeyRotationRequest, KeyRotationResponse,
KeyRotationPolicy, NetworkInfo,
};
use crate::models::{ControlPlane, Network};
use crate::permissions::{
ensure_permission_global, PERM_ACL_READ, PERM_ACL_WRITE, PERM_KEY_POLICY_READ,
PERM_KEY_POLICY_WRITE, PERM_NETWORKS_READ, PERM_NETWORKS_WRITE, PERM_NODES_READ,
PERM_NODES_WRITE, PERM_TOKENS_WRITE, PERM_AUDIT_READ,
};
use axum::extract::{Path, Query, State};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sqlx::PgPool;
use time::OffsetDateTime;
use uuid::Uuid;
#[derive(Debug, Serialize)]
struct NetworkSummary {
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,
created_at: OffsetDateTime,
updated_at: OffsetDateTime,
}
#[derive(Debug, Deserialize)]
struct CreateNetworkRequest {
control_plane_id: Uuid,
name: String,
dns_domain: Option<String>,
requires_approval: Option<bool>,
key_rotation_max_age_seconds: Option<u64>,
bootstrap_token_ttl_seconds: Option<u64>,
bootstrap_token_uses: Option<u32>,
bootstrap_token_tags: Option<Vec<String>>,
}
#[derive(Debug, Serialize)]
struct CreateNetworkResult {
network: NetworkSummary,
bootstrap_token: Option<EnrollmentToken>,
}
#[derive(Debug, Deserialize)]
struct CreateTokenRequest {
ttl_seconds: u64,
uses: u32,
tags: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct RevokeTokenRequest {
token_id: String,
}
#[derive(Debug, Deserialize)]
struct RotateKeysRequest {
machine_public_key: Option<String>,
wg_public_key: Option<String>,
}
#[derive(Debug, Deserialize)]
struct UpdateAclRequest {
policy: Value,
}
#[derive(Debug, Deserialize)]
struct NetworkAuditQuery {
node_id: Option<String>,
limit: Option<usize>,
}
pub fn router() -> Router<crate::app_state::AppState> {
Router::new()
.route("/", get(list_networks).post(create_network))
.route("/:id", get(get_network))
.route("/:id/tokens", post(create_token))
.route("/:id/tokens/revoke", post(revoke_token))
.route("/:id/nodes", get(list_nodes))
.route("/:id/nodes/:node_id/approve", post(approve_node))
.route("/:id/nodes/:node_id/revoke", post(revoke_node))
.route("/:id/nodes/:node_id/rotate-keys", post(rotate_keys))
.route("/:id/nodes/:node_id/keys", get(node_keys))
.route("/:id/acl", get(get_acl).put(update_acl))
.route("/:id/key-policy", get(get_key_policy).put(update_key_policy))
.route("/:id/audit", get(network_audit))
}
async fn list_networks(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
) -> Result<Json<Vec<NetworkSummary>>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_NETWORKS_READ).await?;
let rows = sqlx::query_as::<_, NetworkRow>(
r#"
SELECT n.id,
n.control_plane_id,
n.network_id,
n.name,
n.dns_domain,
n.overlay_v4,
n.overlay_v6,
n.requires_approval,
n.created_at,
n.updated_at,
c.name AS control_plane_name
FROM networks n
JOIN control_planes c ON c.id = n.control_plane_id
ORDER BY n.created_at DESC
"#,
)
.fetch_all(&state.pool)
.await
.map_err(|_| ApiError::Internal)?;
let out = rows
.into_iter()
.map(|row| NetworkSummary {
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,
created_at: row.created_at,
updated_at: row.updated_at,
})
.collect();
Ok(Json(out))
}
async fn get_network(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path(id): Path<Uuid>,
) -> Result<Json<NetworkSummary>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_NETWORKS_READ).await?;
let row = fetch_network_summary(&state.pool, id).await?;
Ok(Json(NetworkSummary {
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,
created_at: row.created_at,
updated_at: row.updated_at,
}))
}
async fn create_network(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Json(req): Json<CreateNetworkRequest>,
) -> Result<Json<CreateNetworkResult>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_NETWORKS_WRITE).await?;
let control_plane = fetch_control_plane(&state.pool, req.control_plane_id).await?;
let client = crate::control_plane::ControlPlaneClient::new(
control_plane.base_url.clone(),
control_plane.admin_token.clone(),
);
let response = client
.create_network(CpCreateNetworkRequest {
name: req.name,
dns_domain: req.dns_domain.clone(),
requires_approval: req.requires_approval,
key_rotation_max_age_seconds: req.key_rotation_max_age_seconds,
bootstrap_token_ttl_seconds: req.bootstrap_token_ttl_seconds,
bootstrap_token_uses: req.bootstrap_token_uses,
bootstrap_token_tags: req.bootstrap_token_tags.clone(),
})
.await
.map_err(|_| ApiError::BadRequest("control plane request failed".to_string()))?;
let record = insert_network(
&state.pool,
&control_plane,
&response.network,
req.dns_domain,
)
.await?;
audit_log::record(
&state.pool,
Some(user.id),
"network.create",
Some("network"),
Some(&record.id.to_string()),
Some(serde_json::json!({
"network_id": response.network.id,
"control_plane_id": control_plane.id,
})),
)
.await?;
let summary = fetch_network_summary(&state.pool, record.id).await?;
Ok(Json(CreateNetworkResult {
network: NetworkSummary {
id: summary.id,
control_plane_id: summary.control_plane_id,
control_plane_name: summary.control_plane_name,
network_id: summary.network_id,
name: summary.name,
dns_domain: summary.dns_domain,
overlay_v4: summary.overlay_v4,
overlay_v6: summary.overlay_v6,
requires_approval: summary.requires_approval,
created_at: summary.created_at,
updated_at: summary.updated_at,
},
bootstrap_token: response.bootstrap_token,
}))
}
async fn create_token(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path(id): Path<Uuid>,
Json(req): Json<CreateTokenRequest>,
) -> Result<Json<CreateTokenResponse>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_TOKENS_WRITE).await?;
let (network, control_plane) = fetch_network_with_cp(&state.pool, id).await?;
let client = client_for(&control_plane);
let response = client
.create_token(
&network.network_id,
CpCreateTokenRequest {
ttl_seconds: req.ttl_seconds,
uses: req.uses,
tags: req.tags,
},
)
.await
.map_err(|_| ApiError::BadRequest("control plane request failed".to_string()))?;
audit_log::record(
&state.pool,
Some(user.id),
"token.create",
Some("network"),
Some(&network.id.to_string()),
None,
)
.await?;
Ok(Json(response))
}
async fn revoke_token(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path(id): Path<Uuid>,
Json(req): Json<RevokeTokenRequest>,
) -> Result<Json<EnrollmentToken>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_TOKENS_WRITE).await?;
let (network, control_plane) = fetch_network_with_cp(&state.pool, id).await?;
let client = client_for(&control_plane);
let response = client
.revoke_token(&req.token_id)
.await
.map_err(|_| ApiError::BadRequest("control plane request failed".to_string()))?;
audit_log::record(
&state.pool,
Some(user.id),
"token.revoke",
Some("network"),
Some(&network.id.to_string()),
Some(serde_json::json!({ "token_id": req.token_id })),
)
.await?;
Ok(Json(response))
}
async fn list_nodes(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path(id): Path<Uuid>,
) -> Result<Json<AdminNodesResponse>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_NODES_READ).await?;
let (network, control_plane) = fetch_network_with_cp(&state.pool, id).await?;
let client = client_for(&control_plane);
let response = client
.list_nodes(&network.network_id)
.await
.map_err(|_| ApiError::BadRequest("control plane request failed".to_string()))?;
Ok(Json(response))
}
async fn approve_node(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path((id, node_id)): Path<(Uuid, String)>,
) -> Result<Json<crate::control_plane::ApproveNodeResponse>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_NODES_WRITE).await?;
let (_network, control_plane) = fetch_network_with_cp(&state.pool, id).await?;
let client = client_for(&control_plane);
let response = client
.approve_node(&node_id)
.await
.map_err(|_| ApiError::BadRequest("control plane request failed".to_string()))?;
audit_log::record(
&state.pool,
Some(user.id),
"node.approve",
Some("node"),
Some(&node_id),
None,
)
.await?;
Ok(Json(response))
}
async fn revoke_node(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path((id, node_id)): Path<(Uuid, String)>,
) -> Result<Json<crate::control_plane::RevokeNodeResponse>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_NODES_WRITE).await?;
let (_network, control_plane) = fetch_network_with_cp(&state.pool, id).await?;
let client = client_for(&control_plane);
let response = client
.revoke_node(&node_id)
.await
.map_err(|_| ApiError::BadRequest("control plane request failed".to_string()))?;
audit_log::record(
&state.pool,
Some(user.id),
"node.revoke",
Some("node"),
Some(&node_id),
None,
)
.await?;
Ok(Json(response))
}
async fn rotate_keys(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path((id, node_id)): Path<(Uuid, String)>,
Json(req): Json<RotateKeysRequest>,
) -> Result<Json<KeyRotationResponse>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_NODES_WRITE).await?;
let (_network, control_plane) = fetch_network_with_cp(&state.pool, id).await?;
let client = client_for(&control_plane);
let response = client
.rotate_keys(
&node_id,
KeyRotationRequest {
machine_public_key: req.machine_public_key,
wg_public_key: req.wg_public_key,
},
)
.await
.map_err(|_| ApiError::BadRequest("control plane request failed".to_string()))?;
audit_log::record(
&state.pool,
Some(user.id),
"node.rotate_keys",
Some("node"),
Some(&node_id),
None,
)
.await?;
Ok(Json(response))
}
async fn node_keys(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path((id, node_id)): Path<(Uuid, String)>,
) -> Result<Json<KeyHistoryResponse>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_NODES_READ).await?;
let (_network, control_plane) = fetch_network_with_cp(&state.pool, id).await?;
let client = client_for(&control_plane);
let response = client
.node_keys(&node_id)
.await
.map_err(|_| ApiError::BadRequest("control plane request failed".to_string()))?;
Ok(Json(response))
}
async fn get_acl(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path(id): Path<Uuid>,
) -> Result<Json<Value>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_ACL_READ).await?;
let (network, control_plane) = fetch_network_with_cp(&state.pool, id).await?;
let client = client_for(&control_plane);
let response = client
.get_acl(&network.network_id)
.await
.map_err(|_| ApiError::BadRequest("control plane request failed".to_string()))?;
Ok(Json(response))
}
async fn update_acl(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path(id): Path<Uuid>,
Json(req): Json<UpdateAclRequest>,
) -> Result<Json<Value>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_ACL_WRITE).await?;
let (network, control_plane) = fetch_network_with_cp(&state.pool, id).await?;
let client = client_for(&control_plane);
let response = client
.update_acl(&network.network_id, req.policy)
.await
.map_err(|_| ApiError::BadRequest("control plane request failed".to_string()))?;
audit_log::record(
&state.pool,
Some(user.id),
"network.update_acl",
Some("network"),
Some(&network.id.to_string()),
None,
)
.await?;
Ok(Json(response))
}
async fn get_key_policy(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path(id): Path<Uuid>,
) -> Result<Json<KeyPolicyResponse>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_KEY_POLICY_READ).await?;
let (network, control_plane) = fetch_network_with_cp(&state.pool, id).await?;
let client = client_for(&control_plane);
let response = client
.get_key_policy(&network.network_id)
.await
.map_err(|_| ApiError::BadRequest("control plane request failed".to_string()))?;
Ok(Json(response))
}
async fn update_key_policy(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path(id): Path<Uuid>,
Json(req): Json<KeyRotationPolicy>,
) -> Result<Json<KeyPolicyResponse>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_KEY_POLICY_WRITE).await?;
let (network, control_plane) = fetch_network_with_cp(&state.pool, id).await?;
let client = client_for(&control_plane);
let response = client
.update_key_policy(&network.network_id, req)
.await
.map_err(|_| ApiError::BadRequest("control plane request failed".to_string()))?;
audit_log::record(
&state.pool,
Some(user.id),
"network.update_key_policy",
Some("network"),
Some(&network.id.to_string()),
None,
)
.await?;
Ok(Json(response))
}
async fn network_audit(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path(id): Path<Uuid>,
Query(query): Query<NetworkAuditQuery>,
) -> Result<Json<crate::control_plane::AuditLogResponse>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_AUDIT_READ).await?;
let (network, control_plane) = fetch_network_with_cp(&state.pool, id).await?;
let client = client_for(&control_plane);
let response = client
.audit_log(crate::control_plane::AuditLogQuery {
network_id: Some(network.network_id),
node_id: query.node_id,
limit: query.limit,
})
.await
.map_err(|_| ApiError::BadRequest("control plane request failed".to_string()))?;
Ok(Json(response))
}
#[derive(sqlx::FromRow)]
struct NetworkRow {
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,
created_at: OffsetDateTime,
updated_at: OffsetDateTime,
}
async fn fetch_network_summary(pool: &PgPool, id: Uuid) -> Result<NetworkRow, ApiError> {
sqlx::query_as::<_, NetworkRow>(
r#"
SELECT n.id,
n.control_plane_id,
n.network_id,
n.name,
n.dns_domain,
n.overlay_v4,
n.overlay_v6,
n.requires_approval,
n.created_at,
n.updated_at,
c.name AS control_plane_name
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 fetch_network_with_cp(
pool: &PgPool,
network_id: Uuid,
) -> Result<(Network, ControlPlane), ApiError> {
let network = sqlx::query_as::<_, Network>(
r#"
SELECT id, control_plane_id, network_id, name, dns_domain, overlay_v4, overlay_v6,
requires_approval, created_at, updated_at
FROM networks
WHERE id = $1
"#,
)
.bind(network_id)
.fetch_optional(pool)
.await
.map_err(|_| ApiError::Internal)?
.ok_or(ApiError::NotFound)?;
let control_plane = fetch_control_plane(pool, network.control_plane_id).await?;
Ok((network, control_plane))
}
async fn fetch_control_plane(pool: &PgPool, id: Uuid) -> Result<ControlPlane, ApiError> {
sqlx::query_as::<_, ControlPlane>(
r#"
SELECT id, name, base_url, admin_token, region, created_at, updated_at
FROM control_planes
WHERE id = $1
"#,
)
.bind(id)
.fetch_optional(pool)
.await
.map_err(|_| ApiError::Internal)?
.ok_or(ApiError::NotFound)
}
async fn insert_network(
pool: &PgPool,
control_plane: &ControlPlane,
network: &NetworkInfo,
dns_override: Option<String>,
) -> Result<Network, ApiError> {
let now = OffsetDateTime::now_utc();
sqlx::query_as::<_, Network>(
r#"
INSERT INTO networks (
id, control_plane_id, network_id, name, dns_domain, overlay_v4, overlay_v6,
requires_approval, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9)
RETURNING id, control_plane_id, network_id, name, dns_domain, overlay_v4, overlay_v6,
requires_approval, created_at, updated_at
"#,
)
.bind(Uuid::new_v4())
.bind(control_plane.id)
.bind(&network.id)
.bind(&network.name)
.bind(dns_override.or_else(|| Some(network.dns_domain.clone())))
.bind(&network.overlay_v4)
.bind(&network.overlay_v6)
.bind(network.requires_approval)
.bind(now)
.fetch_one(pool)
.await
.map_err(|_| ApiError::Internal)
}
fn client_for(control_plane: &ControlPlane) -> crate::control_plane::ControlPlaneClient {
crate::control_plane::ControlPlaneClient::new(
control_plane.base_url.clone(),
control_plane.admin_token.clone(),
)
}

514
backend/src/api/users.rs Normal file
View file

@ -0,0 +1,514 @@
use crate::api::{ApiError, AuthUser};
use crate::audit_log;
use crate::auth::hash_password;
use crate::models::{Membership, User};
use crate::permissions::{
ensure_permission_global, PERM_ROLES_READ, PERM_USERS_READ, PERM_USERS_WRITE,
};
use axum::extract::{Path, State};
use axum::routing::{delete, get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use time::OffsetDateTime;
use uuid::Uuid;
#[derive(Debug, Serialize)]
struct UserSummary {
id: Uuid,
email: String,
display_name: Option<String>,
disabled: bool,
super_admin: bool,
created_at: OffsetDateTime,
updated_at: OffsetDateTime,
}
#[derive(Debug, Serialize)]
struct UserDetail {
user: UserSummary,
memberships: Vec<MembershipSummary>,
}
#[derive(Debug, Serialize)]
struct MembershipSummary {
id: Uuid,
role_id: Uuid,
role_name: String,
scope_type: String,
scope_id: String,
created_at: OffsetDateTime,
}
#[derive(Debug, Serialize)]
struct RoleSummary {
id: Uuid,
name: String,
description: Option<String>,
permissions: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct CreateUserRequest {
email: String,
display_name: Option<String>,
password: String,
role_id: Option<Uuid>,
super_admin: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct UpdateUserRequest {
display_name: Option<String>,
disabled: Option<bool>,
super_admin: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct SetPasswordRequest {
password: String,
}
#[derive(Debug, Deserialize)]
struct AddMembershipRequest {
role_id: Uuid,
scope_type: String,
scope_id: String,
}
pub fn router() -> Router<crate::app_state::AppState> {
Router::new()
.route("/", get(list_users).post(create_user))
.route("/roles", get(list_roles))
.route("/:id", get(get_user).put(update_user))
.route("/:id/password", post(set_password))
.route("/:id/memberships", post(add_membership))
.route("/:id/memberships/:membership_id", delete(delete_membership))
}
async fn list_users(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
) -> Result<Json<Vec<UserSummary>>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_USERS_READ).await?;
let rows = sqlx::query_as::<_, User>(
r#"
SELECT id, email, display_name, password_hash, disabled, super_admin, created_at, updated_at
FROM users
ORDER BY created_at DESC
"#,
)
.fetch_all(&state.pool)
.await
.map_err(|_| ApiError::Internal)?;
Ok(Json(
rows.into_iter()
.map(|row| UserSummary {
id: row.id,
email: row.email,
display_name: row.display_name,
disabled: row.disabled,
super_admin: row.super_admin,
created_at: row.created_at,
updated_at: row.updated_at,
})
.collect(),
))
}
async fn get_user(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path(user_id): Path<Uuid>,
) -> Result<Json<UserDetail>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_USERS_READ).await?;
let record = fetch_user(&state.pool, user_id).await?;
let memberships = fetch_memberships(&state.pool, user_id).await?;
Ok(Json(UserDetail {
user: UserSummary {
id: record.id,
email: record.email,
display_name: record.display_name,
disabled: record.disabled,
super_admin: record.super_admin,
created_at: record.created_at,
updated_at: record.updated_at,
},
memberships,
}))
}
async fn create_user(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Json(req): Json<CreateUserRequest>,
) -> Result<Json<UserDetail>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_USERS_WRITE).await?;
let super_admin = req.super_admin.unwrap_or(false);
if super_admin && !user.super_admin {
return Err(ApiError::Forbidden);
}
let now = OffsetDateTime::now_utc();
let email = req.email.to_lowercase();
let hash = hash_password(&req.password).map_err(|_| ApiError::Internal)?;
let new_user = sqlx::query_as::<_, User>(
r#"
INSERT INTO users (id, email, display_name, password_hash, disabled, super_admin, created_at, updated_at)
VALUES ($1, $2, $3, $4, false, $5, $6, $6)
RETURNING id, email, display_name, password_hash, disabled, super_admin, created_at, updated_at
"#,
)
.bind(Uuid::new_v4())
.bind(email)
.bind(req.display_name)
.bind(hash)
.bind(super_admin)
.bind(now)
.fetch_one(&state.pool)
.await
.map_err(|_| ApiError::Internal)?;
let role_id = match req.role_id {
Some(role) => role,
None => fetch_role_id(&state.pool, "viewer").await?,
};
let membership = insert_membership(&state.pool, new_user.id, role_id, "global", "global").await?;
audit_log::record(
&state.pool,
Some(user.id),
"user.create",
Some("user"),
Some(&new_user.id.to_string()),
None,
)
.await?;
Ok(Json(UserDetail {
user: UserSummary {
id: new_user.id,
email: new_user.email,
display_name: new_user.display_name,
disabled: new_user.disabled,
super_admin: new_user.super_admin,
created_at: new_user.created_at,
updated_at: new_user.updated_at,
},
memberships: vec![membership],
}))
}
async fn update_user(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path(user_id): Path<Uuid>,
Json(req): Json<UpdateUserRequest>,
) -> Result<Json<UserSummary>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_USERS_WRITE).await?;
let existing = fetch_user(&state.pool, user_id).await?;
if req.super_admin.is_some() && !user.super_admin {
return Err(ApiError::Forbidden);
}
if req.disabled.unwrap_or(false) && user_id == user.id {
return Err(ApiError::BadRequest("cannot disable your own account".to_string()));
}
if existing.super_admin && req.super_admin == Some(false) {
let remaining = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM users WHERE super_admin = true AND id != $1",
)
.bind(existing.id)
.fetch_one(&state.pool)
.await
.map_err(|_| ApiError::Internal)?;
if remaining == 0 {
return Err(ApiError::BadRequest("cannot remove last super admin".to_string()));
}
}
let now = OffsetDateTime::now_utc();
let updated = sqlx::query_as::<_, User>(
r#"
UPDATE users
SET display_name = $2, disabled = $3, super_admin = $4, updated_at = $5
WHERE id = $1
RETURNING id, email, display_name, password_hash, disabled, super_admin, created_at, updated_at
"#,
)
.bind(existing.id)
.bind(req.display_name.or(existing.display_name))
.bind(req.disabled.unwrap_or(existing.disabled))
.bind(req.super_admin.unwrap_or(existing.super_admin))
.bind(now)
.fetch_one(&state.pool)
.await
.map_err(|_| ApiError::Internal)?;
audit_log::record(
&state.pool,
Some(user.id),
"user.update",
Some("user"),
Some(&updated.id.to_string()),
None,
)
.await?;
Ok(Json(UserSummary {
id: updated.id,
email: updated.email,
display_name: updated.display_name,
disabled: updated.disabled,
super_admin: updated.super_admin,
created_at: updated.created_at,
updated_at: updated.updated_at,
}))
}
async fn set_password(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path(user_id): Path<Uuid>,
Json(req): Json<SetPasswordRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
if user_id != user.id {
ensure_permission_global(&state.pool, &user, PERM_USERS_WRITE).await?;
}
let hash = hash_password(&req.password).map_err(|_| ApiError::Internal)?;
let now = OffsetDateTime::now_utc();
sqlx::query(
"UPDATE users SET password_hash = $2, updated_at = $3 WHERE id = $1",
)
.bind(user_id)
.bind(hash)
.bind(now)
.execute(&state.pool)
.await
.map_err(|_| ApiError::Internal)?;
audit_log::record(
&state.pool,
Some(user.id),
"user.set_password",
Some("user"),
Some(&user_id.to_string()),
None,
)
.await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
async fn add_membership(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path(user_id): Path<Uuid>,
Json(req): Json<AddMembershipRequest>,
) -> Result<Json<MembershipSummary>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_USERS_WRITE).await?;
let membership = insert_membership(
&state.pool,
user_id,
req.role_id,
&req.scope_type,
&req.scope_id,
)
.await?;
audit_log::record(
&state.pool,
Some(user.id),
"membership.create",
Some("user"),
Some(&user_id.to_string()),
None,
)
.await?;
Ok(Json(membership))
}
async fn delete_membership(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path((_user_id, membership_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<serde_json::Value>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_USERS_WRITE).await?;
sqlx::query("DELETE FROM memberships WHERE id = $1")
.bind(membership_id)
.execute(&state.pool)
.await
.map_err(|_| ApiError::Internal)?;
audit_log::record(
&state.pool,
Some(user.id),
"membership.delete",
Some("membership"),
Some(&membership_id.to_string()),
None,
)
.await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
async fn list_roles(
State(state): State<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
) -> Result<Json<Vec<RoleSummary>>, ApiError> {
ensure_permission_global(&state.pool, &user, PERM_ROLES_READ).await?;
let rows = sqlx::query_as::<_, RolePermissionRow>(
r#"
SELECT r.id, r.name, r.description, rp.permission
FROM roles r
LEFT JOIN role_permissions rp ON rp.role_id = r.id
ORDER BY r.name, rp.permission
"#,
)
.fetch_all(&state.pool)
.await
.map_err(|_| ApiError::Internal)?;
let mut out: Vec<RoleSummary> = Vec::new();
for row in rows {
if let Some(role) = out.iter_mut().find(|role| role.id == row.id) {
if let Some(permission) = row.permission {
role.permissions.push(permission);
}
continue;
}
let mut permissions = Vec::new();
if let Some(permission) = row.permission.clone() {
permissions.push(permission);
}
out.push(RoleSummary {
id: row.id,
name: row.name,
description: row.description,
permissions,
});
}
Ok(Json(out))
}
#[derive(sqlx::FromRow)]
struct RolePermissionRow {
id: Uuid,
name: String,
description: Option<String>,
permission: Option<String>,
}
async fn fetch_user(pool: &PgPool, user_id: Uuid) -> Result<User, ApiError> {
sqlx::query_as::<_, User>(
r#"
SELECT id, email, display_name, password_hash, disabled, super_admin, created_at, updated_at
FROM users
WHERE id = $1
"#,
)
.bind(user_id)
.fetch_optional(pool)
.await
.map_err(|_| ApiError::Internal)?
.ok_or(ApiError::NotFound)
}
async fn fetch_memberships(pool: &PgPool, user_id: Uuid) -> Result<Vec<MembershipSummary>, ApiError> {
let rows = sqlx::query_as::<_, MembershipRow>(
r#"
SELECT m.id, m.role_id, r.name AS role_name, m.scope_type, m.scope_id, m.created_at
FROM memberships m
JOIN roles r ON r.id = m.role_id
WHERE m.user_id = $1
ORDER BY m.created_at DESC
"#,
)
.bind(user_id)
.fetch_all(pool)
.await
.map_err(|_| ApiError::Internal)?;
Ok(rows
.into_iter()
.map(|row| MembershipSummary {
id: row.id,
role_id: row.role_id,
role_name: row.role_name,
scope_type: row.scope_type,
scope_id: row.scope_id,
created_at: row.created_at,
})
.collect())
}
#[derive(sqlx::FromRow)]
struct MembershipRow {
id: Uuid,
role_id: Uuid,
role_name: String,
scope_type: String,
scope_id: String,
created_at: OffsetDateTime,
}
async fn fetch_role_id(pool: &PgPool, role_name: &str) -> Result<Uuid, ApiError> {
sqlx::query_scalar::<_, Uuid>("SELECT id FROM roles WHERE name = $1")
.bind(role_name)
.fetch_one(pool)
.await
.map_err(|_| ApiError::Internal)
}
async fn insert_membership(
pool: &PgPool,
user_id: Uuid,
role_id: Uuid,
scope_type: &str,
scope_id: &str,
) -> Result<MembershipSummary, ApiError> {
let now = OffsetDateTime::now_utc();
let membership = sqlx::query_as::<_, Membership>(
r#"
INSERT INTO memberships (id, user_id, scope_type, scope_id, role_id, created_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, user_id, scope_type, scope_id, role_id, created_at
"#,
)
.bind(Uuid::new_v4())
.bind(user_id)
.bind(scope_type)
.bind(scope_id)
.bind(role_id)
.bind(now)
.fetch_one(pool)
.await
.map_err(|_| ApiError::Internal)?;
let role_name = sqlx::query_scalar::<_, String>("SELECT name FROM roles WHERE id = $1")
.bind(role_id)
.fetch_one(pool)
.await
.map_err(|_| ApiError::Internal)?;
Ok(MembershipSummary {
id: membership.id,
role_id: membership.role_id,
role_name,
scope_type: membership.scope_type,
scope_id: membership.scope_id,
created_at: membership.created_at,
})
}

11
backend/src/app_state.rs Normal file
View file

@ -0,0 +1,11 @@
use crate::config::Config;
use crate::oidc::OidcProvider;
use sqlx::PgPool;
use std::collections::HashMap;
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub config: Config,
pub oidc: HashMap<String, OidcProvider>,
}

33
backend/src/audit_log.rs Normal file
View file

@ -0,0 +1,33 @@
use crate::api::ApiError;
use serde_json::Value;
use sqlx::PgPool;
use time::OffsetDateTime;
use uuid::Uuid;
pub async fn record(
pool: &PgPool,
actor_user_id: Option<Uuid>,
action: &str,
target_type: Option<&str>,
target_id: Option<&str>,
metadata: Option<Value>,
) -> Result<(), ApiError> {
let now = OffsetDateTime::now_utc();
sqlx::query(
r#"
INSERT INTO audit_log (id, actor_user_id, action, target_type, target_id, metadata, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
"#,
)
.bind(Uuid::new_v4())
.bind(actor_user_id)
.bind(action)
.bind(target_type)
.bind(target_id)
.bind(metadata)
.bind(now)
.execute(pool)
.await
.map_err(|_| ApiError::Internal)?;
Ok(())
}

122
backend/src/auth.rs Normal file
View file

@ -0,0 +1,122 @@
use crate::models::{Session, User};
use anyhow::{anyhow, Result};
use argon2::{password_hash::SaltString, Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use rand::RngCore;
use sha2::{Digest, Sha256};
use sqlx::PgPool;
use time::{Duration, OffsetDateTime};
use uuid::Uuid;
pub const SESSION_COOKIE: &str = "ls_admin_session";
pub fn hash_password(password: &str) -> Result<String> {
let salt = SaltString::generate(&mut rand::thread_rng());
let argon = Argon2::default();
Ok(argon
.hash_password(password.as_bytes(), &salt)
.map_err(|err| anyhow!("password hash failed: {}", err))?
.to_string())
}
pub fn verify_password(hash: &str, password: &str) -> Result<bool> {
let parsed = PasswordHash::new(hash)
.map_err(|err| anyhow!("invalid password hash: {}", err))?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok())
}
pub fn generate_token() -> String {
let mut random = [0u8; 32];
rand::thread_rng().fill_bytes(&mut random);
hex::encode(random)
}
pub fn hash_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
hex::encode(hasher.finalize())
}
pub async fn create_session(
pool: &PgPool,
user_id: Uuid,
ttl: Duration,
user_agent: Option<String>,
ip: Option<String>,
) -> Result<String> {
let token = generate_token();
let token_hash = hash_token(&token);
let now = OffsetDateTime::now_utc();
let expires_at = now + ttl;
sqlx::query_as::<_, Session>(
r#"
INSERT INTO sessions (id, user_id, token_hash, expires_at, created_at, last_seen_at, user_agent, ip)
VALUES ($1, $2, $3, $4, $5, $5, $6, $7)
RETURNING id, user_id, token_hash, expires_at, created_at, last_seen_at, user_agent, ip
"#,
)
.bind(Uuid::new_v4())
.bind(user_id)
.bind(token_hash)
.bind(expires_at)
.bind(now)
.bind(user_agent)
.bind(ip)
.fetch_one(pool)
.await?;
Ok(token)
}
pub async fn delete_session(pool: &PgPool, token: &str) -> Result<()> {
let token_hash = hash_token(token);
sqlx::query("DELETE FROM sessions WHERE token_hash = $1")
.bind(token_hash)
.execute(pool)
.await?;
Ok(())
}
pub async fn session_user(pool: &PgPool, token: &str) -> Result<Option<User>> {
let token_hash = hash_token(token);
let now = OffsetDateTime::now_utc();
let session = sqlx::query_as::<_, Session>(
r#"
SELECT id, user_id, token_hash, expires_at, created_at, last_seen_at, user_agent, ip
FROM sessions
WHERE token_hash = $1
"#,
)
.bind(&token_hash)
.fetch_optional(pool)
.await?;
let Some(session) = session else {
return Ok(None);
};
if session.expires_at <= now {
return Ok(None);
}
if now - Duration::minutes(5) > session.last_seen_at {
sqlx::query("UPDATE sessions SET last_seen_at = $1 WHERE id = $2")
.bind(now)
.bind(session.id)
.execute(pool)
.await?;
}
let user = sqlx::query_as::<_, User>(
r#"
SELECT id, email, display_name, password_hash, disabled, super_admin, created_at, updated_at
FROM users
WHERE id = $1
"#,
)
.bind(session.user_id)
.fetch_optional(pool)
.await?;
Ok(user)
}

93
backend/src/config.rs Normal file
View file

@ -0,0 +1,93 @@
use serde::Deserialize;
use std::collections::HashMap;
#[derive(Debug, Clone, Deserialize)]
pub struct Config {
pub server: ServerConfig,
pub database: DatabaseConfig,
pub auth: AuthConfig,
#[serde(default)]
pub oidc: Vec<OidcProviderConfig>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ServerConfig {
#[serde(default = "default_bind")]
pub bind: String,
#[serde(default = "default_base_url")]
pub base_url: String,
#[serde(default)]
pub allowed_origins: Vec<String>,
#[serde(default)]
pub static_dir: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DatabaseConfig {
pub url: String,
#[serde(default = "default_max_connections")]
pub max_connections: u32,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AuthConfig {
#[serde(default = "default_session_ttl_minutes")]
pub session_ttl_minutes: i64,
#[serde(default)]
pub cookie_domain: Option<String>,
#[serde(default = "default_cookie_secure")]
pub cookie_secure: bool,
#[serde(default)]
pub bootstrap_admin_email: Option<String>,
#[serde(default)]
pub bootstrap_admin_password: Option<String>,
#[serde(default = "default_allow_user_signup")]
pub allow_user_signup: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct OidcProviderConfig {
pub id: String,
pub name: String,
pub issuer_url: String,
pub client_id: String,
pub client_secret: String,
#[serde(default)]
pub scopes: Vec<String>,
#[serde(default)]
pub extra_params: HashMap<String, String>,
}
impl Config {
pub fn load() -> Result<Self, config::ConfigError> {
let settings = config::Config::builder()
.add_source(config::File::with_name("config").required(false))
.add_source(config::Environment::with_prefix("LS_ADMIN").separator("__"))
.build()?;
settings.try_deserialize()
}
}
fn default_bind() -> String {
"0.0.0.0:8081".to_string()
}
fn default_base_url() -> String {
"http://localhost:8081".to_string()
}
fn default_max_connections() -> u32 {
20
}
fn default_session_ttl_minutes() -> i64 {
720
}
fn default_cookie_secure() -> bool {
false
}
fn default_allow_user_signup() -> bool {
false
}

View file

@ -0,0 +1,366 @@
use anyhow::{anyhow, Result};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Clone)]
pub struct ControlPlaneClient {
base_url: String,
admin_token: Option<String>,
client: Client,
}
impl ControlPlaneClient {
pub fn new(base_url: String, admin_token: Option<String>) -> Self {
Self {
base_url: base_url.trim_end_matches('/').to_string(),
admin_token,
client: Client::new(),
}
}
fn url(&self, path: &str) -> String {
format!("{}{}", self.base_url, path)
}
fn auth_header(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if let Some(token) = &self.admin_token {
req.header("X-Lightscale-Admin-Token", token)
} else {
req
}
}
pub async fn create_network(&self, request: CreateNetworkRequest) -> Result<CreateNetworkResponse> {
let req = self.client.post(self.url("/v1/networks")).json(&request);
let resp = self
.auth_header(req)
.send()
.await?
.error_for_status()
.map_err(|err| anyhow!("create network failed: {}", err))?;
Ok(resp.json().await?)
}
pub async fn create_token(&self, network_id: &str, request: CreateTokenRequest) -> Result<CreateTokenResponse> {
let req = self
.client
.post(self.url(&format!("/v1/networks/{}/tokens", network_id)))
.json(&request);
let resp = self
.auth_header(req)
.send()
.await?
.error_for_status()
.map_err(|err| anyhow!("create token failed: {}", err))?;
Ok(resp.json().await?)
}
pub async fn revoke_token(&self, token_id: &str) -> Result<EnrollmentToken> {
let req = self
.client
.post(self.url(&format!("/v1/tokens/{}/revoke", token_id)));
let resp = self
.auth_header(req)
.send()
.await?
.error_for_status()
.map_err(|err| anyhow!("revoke token failed: {}", err))?;
Ok(resp.json().await?)
}
pub async fn list_nodes(&self, network_id: &str) -> Result<AdminNodesResponse> {
let req = self
.client
.get(self.url(&format!("/v1/admin/networks/{}/nodes", network_id)));
let resp = self
.auth_header(req)
.send()
.await?
.error_for_status()
.map_err(|err| anyhow!("list nodes failed: {}", err))?;
Ok(resp.json().await?)
}
pub async fn approve_node(&self, node_id: &str) -> Result<ApproveNodeResponse> {
let req = self
.client
.post(self.url(&format!("/v1/admin/nodes/{}/approve", node_id)));
let resp = self
.auth_header(req)
.send()
.await?
.error_for_status()
.map_err(|err| anyhow!("approve node failed: {}", err))?;
Ok(resp.json().await?)
}
pub async fn rotate_keys(&self, node_id: &str, request: KeyRotationRequest) -> Result<KeyRotationResponse> {
let req = self
.client
.post(self.url(&format!("/v1/nodes/{}/rotate-keys", node_id)))
.json(&request);
let resp = self
.auth_header(req)
.send()
.await?
.error_for_status()
.map_err(|err| anyhow!("rotate keys failed: {}", err))?;
Ok(resp.json().await?)
}
pub async fn revoke_node(&self, node_id: &str) -> Result<RevokeNodeResponse> {
let req = self
.client
.post(self.url(&format!("/v1/nodes/{}/revoke", node_id)));
let resp = self
.auth_header(req)
.send()
.await?
.error_for_status()
.map_err(|err| anyhow!("revoke node failed: {}", err))?;
Ok(resp.json().await?)
}
pub async fn node_keys(&self, node_id: &str) -> Result<KeyHistoryResponse> {
let req = self
.client
.get(self.url(&format!("/v1/nodes/{}/keys", node_id)));
let resp = self
.auth_header(req)
.send()
.await?
.error_for_status()
.map_err(|err| anyhow!("node keys failed: {}", err))?;
Ok(resp.json().await?)
}
pub async fn get_acl(&self, network_id: &str) -> Result<Value> {
let req = self
.client
.get(self.url(&format!("/v1/networks/{}/acl", network_id)));
let resp = self
.auth_header(req)
.send()
.await?
.error_for_status()
.map_err(|err| anyhow!("get acl failed: {}", err))?;
Ok(resp.json().await?)
}
pub async fn update_acl(&self, network_id: &str, policy: Value) -> Result<Value> {
let req = self
.client
.put(self.url(&format!("/v1/networks/{}/acl", network_id)))
.json(&serde_json::json!({ "policy": policy }));
let resp = self
.auth_header(req)
.send()
.await?
.error_for_status()
.map_err(|err| anyhow!("update acl failed: {}", err))?;
Ok(resp.json().await?)
}
pub async fn get_key_policy(&self, network_id: &str) -> Result<KeyPolicyResponse> {
let req = self
.client
.get(self.url(&format!("/v1/networks/{}/key-policy", network_id)));
let resp = self
.auth_header(req)
.send()
.await?
.error_for_status()
.map_err(|err| anyhow!("get key policy failed: {}", err))?;
Ok(resp.json().await?)
}
pub async fn update_key_policy(&self, network_id: &str, policy: KeyRotationPolicy) -> Result<KeyPolicyResponse> {
let req = self
.client
.put(self.url(&format!("/v1/networks/{}/key-policy", network_id)))
.json(&policy);
let resp = self
.auth_header(req)
.send()
.await?
.error_for_status()
.map_err(|err| anyhow!("update key policy failed: {}", err))?;
Ok(resp.json().await?)
}
pub async fn audit_log(&self, params: AuditLogQuery) -> Result<AuditLogResponse> {
let req = self.client.get(self.url("/v1/audit")).query(&params);
let resp = self
.auth_header(req)
.send()
.await?
.error_for_status()
.map_err(|err| anyhow!("audit log failed: {}", err))?;
Ok(resp.json().await?)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CreateNetworkRequest {
pub name: String,
pub dns_domain: Option<String>,
pub requires_approval: Option<bool>,
pub key_rotation_max_age_seconds: Option<u64>,
pub bootstrap_token_ttl_seconds: Option<u64>,
pub bootstrap_token_uses: Option<u32>,
pub bootstrap_token_tags: Option<Vec<String>>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CreateNetworkResponse {
pub network: NetworkInfo,
pub bootstrap_token: Option<EnrollmentToken>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct NetworkInfo {
pub id: String,
pub name: String,
pub overlay_v4: String,
pub overlay_v6: String,
pub dns_domain: String,
pub requires_approval: bool,
#[serde(default)]
pub key_rotation_max_age_seconds: Option<u64>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct EnrollmentToken {
pub token: String,
pub expires_at: i64,
pub uses_left: u32,
pub tags: Vec<String>,
pub revoked_at: Option<i64>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CreateTokenRequest {
pub ttl_seconds: u64,
pub uses: u32,
pub tags: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CreateTokenResponse {
pub token: EnrollmentToken,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AdminNodesResponse {
pub nodes: Vec<NodeInfo>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct NodeInfo {
pub id: String,
pub name: String,
pub dns_name: String,
pub ipv4: String,
pub ipv6: String,
pub wg_public_key: String,
pub machine_public_key: String,
pub endpoints: Vec<String>,
pub tags: Vec<String>,
pub routes: Vec<Route>,
pub last_seen: i64,
pub approved: bool,
#[serde(default)]
pub key_rotation_required: bool,
#[serde(default)]
pub revoked: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Route {
pub prefix: String,
pub kind: RouteKind,
pub enabled: bool,
#[serde(default)]
pub mapped_prefix: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RouteKind {
Subnet,
Exit,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ApproveNodeResponse {
pub node_id: String,
pub approved: bool,
pub approved_at: Option<i64>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct KeyRotationRequest {
pub machine_public_key: Option<String>,
pub wg_public_key: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct KeyRotationResponse {
pub node_id: String,
pub machine_public_key: String,
pub wg_public_key: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RevokeNodeResponse {
pub node_id: String,
pub revoked_at: Option<i64>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct KeyHistoryResponse {
pub node_id: String,
pub keys: Vec<KeyRecord>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct KeyRecord {
pub key_type: String,
pub public_key: String,
pub created_at: i64,
pub revoked_at: Option<i64>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct KeyRotationPolicy {
pub max_age_seconds: Option<u64>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct KeyPolicyResponse {
pub policy: KeyRotationPolicy,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AuditLogResponse {
pub entries: Vec<AuditEntry>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AuditEntry {
pub id: String,
pub network_id: Option<String>,
pub node_id: Option<String>,
pub action: String,
pub timestamp: i64,
#[serde(default)]
pub detail: Option<Value>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AuditLogQuery {
pub network_id: Option<String>,
pub node_id: Option<String>,
pub limit: Option<usize>,
}

9
backend/src/db.rs Normal file
View file

@ -0,0 +1,9 @@
use crate::config::DatabaseConfig;
use sqlx::{postgres::PgPoolOptions, PgPool};
pub async fn connect(config: &DatabaseConfig) -> Result<PgPool, sqlx::Error> {
PgPoolOptions::new()
.max_connections(config.max_connections)
.connect(&config.url)
.await
}

87
backend/src/main.rs Normal file
View file

@ -0,0 +1,87 @@
mod api;
mod app_state;
mod audit_log;
mod auth;
mod config;
mod control_plane;
mod db;
mod models;
mod oidc;
mod permissions;
mod rbac;
use crate::api::auth::ensure_bootstrap_admin;
use crate::app_state::AppState;
use crate::config::Config;
use axum::routing::get;
use axum::Router;
use std::net::SocketAddr;
use tower_http::cors::{AllowOrigin, CorsLayer, Any};
use tower_http::services::{ServeDir, ServeFile};
use tower_http::trace::TraceLayer;
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.init();
let config = Config::load()?;
let pool = db::connect(&config.database).await?;
sqlx::migrate!("./migrations").run(&pool).await?;
ensure_bootstrap_admin(&pool, &config).await?;
let oidc = oidc::load_providers(&config.oidc, &config.server.base_url).await?;
let state = AppState {
pool,
config: config.clone(),
oidc,
};
let mut app = Router::new()
.route("/healthz", get(|| async { "ok" }))
.nest("/admin/api", api::router())
.layer(TraceLayer::new_for_http());
if let Some(static_dir) = &config.server.static_dir {
let index_path = format!("{}/index.html", static_dir.trim_end_matches('/'));
let service = ServeDir::new(static_dir).fallback(ServeFile::new(index_path));
app = app.nest_service("/", service);
}
if let Some(cors_layer) = build_cors(&config.server.allowed_origins) {
app = app.layer(cors_layer);
}
let app = app.with_state(state);
let addr: SocketAddr = config.server.bind.parse()?;
tracing::info!("lightscale-admin listening on {}", addr);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
fn build_cors(allowed_origins: &[String]) -> Option<CorsLayer> {
if allowed_origins.is_empty() {
return None;
}
let origins = allowed_origins
.iter()
.map(|origin| origin.parse())
.collect::<Result<Vec<_>, _>>()
.ok()?;
Some(
CorsLayer::new()
.allow_origin(AllowOrigin::list(origins))
.allow_methods(Any)
.allow_headers(Any)
.allow_credentials(true),
)
}

82
backend/src/models.rs Normal file
View file

@ -0,0 +1,82 @@
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use time::OffsetDateTime;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct User {
pub id: Uuid,
pub email: String,
pub display_name: Option<String>,
pub password_hash: Option<String>,
pub disabled: bool,
pub super_admin: bool,
pub created_at: OffsetDateTime,
pub updated_at: OffsetDateTime,
}
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Role {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub created_at: OffsetDateTime,
}
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Membership {
pub id: Uuid,
pub user_id: Uuid,
pub scope_type: String,
pub scope_id: String,
pub role_id: Uuid,
pub created_at: OffsetDateTime,
}
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct ControlPlane {
pub id: Uuid,
pub name: String,
pub base_url: String,
pub admin_token: Option<String>,
pub region: Option<String>,
pub created_at: OffsetDateTime,
pub updated_at: OffsetDateTime,
}
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Network {
pub id: Uuid,
pub control_plane_id: Uuid,
pub network_id: String,
pub name: String,
pub dns_domain: Option<String>,
pub overlay_v4: Option<String>,
pub overlay_v6: Option<String>,
pub requires_approval: bool,
pub created_at: OffsetDateTime,
pub updated_at: OffsetDateTime,
}
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Session {
pub id: Uuid,
pub user_id: Uuid,
pub token_hash: String,
pub expires_at: OffsetDateTime,
pub created_at: OffsetDateTime,
pub last_seen_at: OffsetDateTime,
pub user_agent: Option<String>,
pub ip: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct AuditEntry {
pub id: Uuid,
pub actor_user_id: Option<Uuid>,
pub action: String,
pub target_type: Option<String>,
pub target_id: Option<String>,
pub metadata: Option<serde_json::Value>,
pub created_at: OffsetDateTime,
}

109
backend/src/oidc.rs Normal file
View file

@ -0,0 +1,109 @@
use crate::config::OidcProviderConfig;
use anyhow::{anyhow, Result};
use openidconnect::core::{CoreClient, CoreProviderMetadata, CoreResponseType};
use openidconnect::reqwest::async_http_client;
use openidconnect::{
AuthenticationFlow, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce,
PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope,
};
use std::collections::HashMap;
#[derive(Clone)]
pub struct OidcProvider {
pub id: String,
pub name: String,
pub client: CoreClient,
pub scopes: Vec<String>,
pub extra_params: HashMap<String, String>,
}
pub async fn load_providers(
providers: &[OidcProviderConfig],
base_url: &str,
) -> Result<HashMap<String, OidcProvider>> {
let mut out = HashMap::new();
for provider in providers {
let issuer = IssuerUrl::new(provider.issuer_url.clone())?;
let metadata = CoreProviderMetadata::discover_async(issuer, async_http_client).await?;
let redirect = format!(
"{}/admin/api/auth/oidc/{}/callback",
base_url.trim_end_matches('/'),
provider.id
);
let client = CoreClient::from_provider_metadata(
metadata,
ClientId::new(provider.client_id.clone()),
Some(ClientSecret::new(provider.client_secret.clone())),
)
.set_redirect_uri(RedirectUrl::new(redirect)?);
out.insert(
provider.id.clone(),
OidcProvider {
id: provider.id.clone(),
name: provider.name.clone(),
client,
scopes: provider.scopes.clone(),
extra_params: provider.extra_params.clone(),
},
);
}
Ok(out)
}
pub struct OidcAuthRequest {
pub url: String,
pub state: String,
pub nonce: String,
pub verifier: String,
}
pub fn build_auth_request(provider: &OidcProvider) -> Result<OidcAuthRequest> {
let (challenge, verifier) = PkceCodeChallenge::new_random_sha256();
let mut request = provider
.client
.authorize_url(
AuthenticationFlow::<CoreResponseType>::AuthorizationCode,
CsrfToken::new_random,
Nonce::new_random,
)
.set_pkce_challenge(challenge);
if provider.scopes.is_empty() {
request = request.add_scope(Scope::new("openid".to_string()));
request = request.add_scope(Scope::new("email".to_string()));
request = request.add_scope(Scope::new("profile".to_string()));
} else {
for scope in &provider.scopes {
request = request.add_scope(Scope::new(scope.clone()));
}
}
for (key, value) in &provider.extra_params {
request = request.add_extra_param(key, value);
}
let (url, csrf, nonce) = request.url();
Ok(OidcAuthRequest {
url: url.to_string(),
state: csrf.secret().to_string(),
nonce: nonce.secret().to_string(),
verifier: verifier.secret().to_string(),
})
}
pub async fn exchange_code(
provider: &OidcProvider,
code: String,
verifier: String,
) -> Result<openidconnect::core::CoreTokenResponse> {
let token = provider
.client
.exchange_code(AuthorizationCode::new(code))
.set_pkce_verifier(PkceCodeVerifier::new(verifier))
.request_async(async_http_client)
.await
.map_err(|err| anyhow!("oidc token exchange failed: {}", err))?;
Ok(token)
}

View file

@ -0,0 +1,49 @@
use crate::api::ApiError;
use crate::models::User;
use crate::rbac::{self, SCOPE_GLOBAL};
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_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_ACL_READ: &str = "acl:read";
pub const PERM_ACL_WRITE: &str = "acl:write";
pub const PERM_KEY_POLICY_READ: &str = "key_policy:read";
pub const PERM_KEY_POLICY_WRITE: &str = "key_policy:write";
pub const PERM_AUDIT_READ: &str = "audit:read";
pub const PERM_USERS_READ: &str = "users:read";
pub const PERM_USERS_WRITE: &str = "users:write";
pub const PERM_ROLES_READ: &str = "roles:read";
pub const PERM_ROLES_WRITE: &str = "roles:write";
pub async fn ensure_permission_global(
pool: &PgPool,
user: &User,
permission: &str,
) -> Result<(), ApiError> {
ensure_permission(pool, user, permission, SCOPE_GLOBAL, SCOPE_GLOBAL).await
}
pub async fn ensure_permission(
pool: &PgPool,
user: &User,
permission: &str,
scope_type: &str,
scope_id: &str,
) -> Result<(), ApiError> {
if user.super_admin {
return Ok(());
}
let allowed = rbac::user_has_permission(pool, user.id, permission, scope_type, scope_id)
.await
.map_err(|_| ApiError::Internal)?;
if allowed {
Ok(())
} else {
Err(ApiError::Forbidden)
}
}

48
backend/src/rbac.rs Normal file
View file

@ -0,0 +1,48 @@
use anyhow::Result;
use sqlx::PgPool;
use uuid::Uuid;
pub const SCOPE_GLOBAL: &str = "global";
pub async fn user_has_permission(
pool: &PgPool,
user_id: Uuid,
permission: &str,
scope_type: &str,
scope_id: &str,
) -> Result<bool> {
let record = sqlx::query_scalar::<_, bool>(
"SELECT super_admin FROM users WHERE id = $1",
)
.bind(user_id)
.fetch_one(pool)
.await?;
if record {
return Ok(true);
}
let permitted = sqlx::query_scalar::<_, bool>(
"\
SELECT EXISTS (\n\
SELECT 1\n\
FROM memberships m\n\
JOIN role_permissions rp ON rp.role_id = m.role_id\n\
WHERE m.user_id = $1\n\
AND rp.permission = $2\n\
AND (\n\
(m.scope_type = 'global' AND m.scope_id = 'global')\n\
OR (m.scope_type = $3 AND m.scope_id = $4)\n\
)\n\
)\n\
",
)
.bind(user_id)
.bind(permission)
.bind(scope_type)
.bind(scope_id)
.fetch_one(pool)
.await?;
Ok(permitted)
}

25
config.example.toml Normal file
View file

@ -0,0 +1,25 @@
[server]
bind = "0.0.0.0:8081"
base_url = "http://localhost:8081"
allowed_origins = ["http://localhost:5173"]
static_dir = "frontend/dist"
[database]
url = "postgresql://root@localhost:26257/lightscale_admin?sslmode=disable"
max_connections = 20
[auth]
session_ttl_minutes = 720
cookie_secure = false
# cookie_domain = "admin.lightscale.local"
bootstrap_admin_email = "admin@example.com"
bootstrap_admin_password = "change-me"
allow_user_signup = false
[[oidc]]
id = "google"
name = "Google"
issuer_url = "https://accounts.google.com"
client_id = ""
client_secret = ""
scopes = ["openid", "email", "profile"]

11
docker-compose.yml Normal file
View file

@ -0,0 +1,11 @@
services:
cockroach:
image: cockroachdb/cockroach:v23.1.11
command: start-single-node --insecure --http-addr=0.0.0.0:8080
ports:
- "26257:26257"
- "8080:8080"
volumes:
- cockroach-data:/cockroach/cockroach-data
volumes:
cockroach-data:

24
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
frontend/README.md Normal file
View file

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
frontend/eslint.config.js Normal file
View file

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3269
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

30
frontend/package.json Normal file
View file

@ -0,0 +1,30 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

1
frontend/public/vite.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

544
frontend/src/App.css Normal file
View file

@ -0,0 +1,544 @@
:root {
--shadow: 0 24px 60px rgba(31, 27, 22, 0.14);
--shadow-soft: 0 12px 30px rgba(31, 27, 22, 0.08);
}
#root {
min-height: 100vh;
}
.screen {
min-height: 100vh;
padding: 48px;
display: flex;
align-items: center;
justify-content: center;
}
.screen.center {
flex-direction: column;
}
.loader {
font-size: 1.1rem;
letter-spacing: 0.02em;
}
.login-grid {
width: min(1100px, 100%);
display: grid;
grid-template-columns: minmax(280px, 1fr) minmax(320px, 1.3fr);
gap: 32px;
}
.login-panel {
background: var(--surface);
border-radius: 24px;
padding: 32px;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
gap: 24px;
}
.login-hero {
background: linear-gradient(140deg, rgba(242, 92, 42, 0.15), rgba(43, 154, 143, 0.18));
border-radius: 32px;
padding: 40px;
position: relative;
overflow: hidden;
}
.login-hero::after {
content: '';
position: absolute;
top: -40%;
right: -30%;
width: 320px;
height: 320px;
background: radial-gradient(circle, rgba(246, 196, 83, 0.5), transparent 70%);
opacity: 0.6;
}
.hero-card {
position: relative;
z-index: 1;
}
.hero-title {
font-family: var(--font-display);
font-size: 2.2rem;
margin-bottom: 12px;
}
.hero-copy {
font-size: 1rem;
color: var(--ink-soft);
margin-bottom: 24px;
}
.hero-points {
display: grid;
gap: 16px;
}
.hero-points h4 {
margin-bottom: 6px;
font-size: 1rem;
}
.brand {
display: flex;
align-items: center;
gap: 16px;
}
.brand-mark {
display: grid;
place-items: center;
width: 48px;
height: 48px;
border-radius: 14px;
background: var(--accent);
color: #fff;
font-weight: 700;
}
.brand-title {
font-family: var(--font-display);
font-size: 1.4rem;
margin: 0;
}
.brand-sub {
color: var(--ink-soft);
margin: 4px 0 0;
}
.app-shell {
display: grid;
grid-template-columns: 280px 1fr;
min-height: 100vh;
}
.sidebar {
padding: 32px 24px;
background: linear-gradient(180deg, rgba(31, 27, 22, 0.95), rgba(31, 27, 22, 0.85));
color: #f5efe6;
display: flex;
flex-direction: column;
gap: 32px;
}
.brand-mini {
display: flex;
gap: 12px;
align-items: center;
}
.brand-mini span {
width: 40px;
height: 40px;
border-radius: 12px;
background: rgba(242, 92, 42, 0.9);
display: grid;
place-items: center;
font-weight: 700;
}
.nav {
display: grid;
gap: 12px;
}
.nav button {
background: transparent;
border: 1px solid rgba(245, 239, 230, 0.1);
color: inherit;
padding: 12px 14px;
border-radius: 14px;
text-align: left;
cursor: pointer;
transition: all 0.2s ease;
}
.nav button small {
display: block;
color: rgba(245, 239, 230, 0.6);
font-size: 0.75rem;
}
.nav button.active,
.nav button:hover {
background: rgba(245, 239, 230, 0.12);
border-color: rgba(245, 239, 230, 0.3);
}
.sidebar-footer {
margin-top: auto;
display: flex;
flex-direction: column;
gap: 12px;
color: rgba(245, 239, 230, 0.8);
}
.sidebar-footer button {
color: inherit;
}
.main {
padding: 32px 40px 64px;
}
.topbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 24px;
margin-bottom: 24px;
}
.topbar h1 {
font-family: var(--font-display);
margin-bottom: 8px;
}
.selector-row {
display: flex;
align-items: flex-end;
gap: 16px;
flex-wrap: wrap;
}
.selector-row label {
min-width: 180px;
}
.banner {
padding: 12px 16px;
border-radius: 12px;
margin-bottom: 24px;
font-weight: 500;
}
.banner.success {
background: rgba(43, 154, 143, 0.15);
color: #1d6f66;
}
.banner.error {
background: rgba(242, 92, 42, 0.16);
color: #a93b16;
}
.banner.warning {
background: rgba(246, 196, 83, 0.2);
color: #8a5e12;
}
.banner.info {
background: rgba(31, 27, 22, 0.08);
color: var(--ink);
}
.content {
display: grid;
gap: 24px;
}
.grid {
display: grid;
gap: 24px;
}
.grid.two {
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
}
.card {
background: var(--surface);
padding: 24px;
border-radius: 20px;
box-shadow: var(--shadow-soft);
display: flex;
flex-direction: column;
gap: 16px;
animation: riseIn 0.6s ease both;
}
.card.highlight {
background: linear-gradient(140deg, rgba(43, 154, 143, 0.12), rgba(246, 196, 83, 0.25));
}
.stack {
display: grid;
gap: 12px;
}
.inline {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
label span {
display: block;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-soft);
margin-bottom: 6px;
}
input,
select,
textarea {
width: 100%;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: #fff;
font-family: inherit;
font-size: 0.95rem;
}
textarea {
min-height: 240px;
font-family: var(--font-mono);
font-size: 0.85rem;
}
button {
border: none;
border-radius: 12px;
padding: 10px 16px;
background: var(--accent);
color: #fff;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
button:hover {
transform: translateY(-1px);
box-shadow: 0 10px 20px rgba(242, 92, 42, 0.2);
}
button.ghost {
background: transparent;
color: var(--ink);
border: 1px solid var(--border);
box-shadow: none;
}
button.ghost:hover {
background: rgba(31, 27, 22, 0.05);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.button-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.list {
display: grid;
gap: 12px;
}
.list-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 14px;
background: rgba(31, 27, 22, 0.03);
animation: riseIn 0.5s ease both;
}
.list-row.selectable {
border: 1px solid transparent;
cursor: pointer;
}
.list-row.selectable.active,
.list-row.selectable:hover {
border-color: rgba(31, 27, 22, 0.2);
background: rgba(31, 27, 22, 0.05);
}
.tag-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 6px;
}
.tag {
padding: 4px 8px;
border-radius: 999px;
background: rgba(31, 27, 22, 0.08);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.tag.success {
background: rgba(43, 154, 143, 0.18);
color: #1c6b63;
}
.tag.warning {
background: rgba(246, 196, 83, 0.2);
color: #8a5e12;
}
.tag.danger {
background: rgba(242, 92, 42, 0.2);
color: #a93b16;
}
.tag-row .tag {
margin-top: 6px;
}
.mono {
font-family: var(--font-mono);
font-size: 0.8rem;
}
.pill-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.pill {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
padding: 8px 14px;
border: 1px solid var(--border);
background: #fff;
font-weight: 600;
cursor: pointer;
}
.pill.active {
background: var(--ink);
color: #fff;
border-color: var(--ink);
}
.notice {
padding: 8px 12px;
border-radius: 10px;
font-size: 0.85rem;
}
.notice.error {
background: rgba(242, 92, 42, 0.12);
color: #a93b16;
}
.callout {
padding: 16px;
border-radius: 14px;
border: 1px dashed var(--border);
background: rgba(43, 154, 143, 0.08);
display: grid;
gap: 8px;
}
.callout code {
font-family: var(--font-mono);
font-size: 0.9rem;
}
.section-title {
font-weight: 600;
}
.meta {
display: flex;
gap: 12px;
flex-wrap: wrap;
color: var(--ink-soft);
font-size: 0.8rem;
}
.divider {
height: 1px;
background: rgba(31, 27, 22, 0.1);
}
.toggle {
display: flex;
gap: 12px;
align-items: center;
}
.toggle input {
width: auto;
}
.muted {
color: var(--ink-soft);
}
@keyframes riseIn {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 960px) {
.app-shell {
grid-template-columns: 1fr;
}
.sidebar {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
}
.nav {
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
}
.main {
padding: 24px;
}
.login-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.selector-row {
flex-direction: column;
align-items: stretch;
}
.topbar {
flex-direction: column;
align-items: flex-start;
}
}

1848
frontend/src/App.tsx Normal file

File diff suppressed because it is too large Load diff

421
frontend/src/api.ts Normal file
View file

@ -0,0 +1,421 @@
const API_BASE = '/admin/api';
type ApiError = { error?: string };
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers = new Headers(options.headers);
if (options.body && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
const response = await fetch(`${API_BASE}${path}`, {
...options,
headers,
credentials: 'include',
});
const text = await response.text();
const data = text ? (JSON.parse(text) as T | ApiError) : undefined;
if (!response.ok) {
const err = (data as ApiError | undefined)?.error;
throw new Error(err || `Request failed (${response.status})`);
}
return data as T;
}
export type UserSummary = {
id: string;
email: string;
display_name?: string | null;
disabled: boolean;
super_admin: boolean;
created_at: string;
updated_at: string;
};
export type MembershipSummary = {
id: string;
role_id: string;
role_name: string;
scope_type: string;
scope_id: string;
created_at: string;
};
export type UserDetail = {
user: UserSummary;
memberships: MembershipSummary[];
};
export type RoleSummary = {
id: string;
name: string;
description?: string | null;
permissions: string[];
};
export type ControlPlaneSummary = {
id: string;
name: string;
base_url: string;
region?: string | null;
has_admin_token: boolean;
created_at: string;
updated_at: string;
};
export type NetworkSummary = {
id: string;
control_plane_id: string;
control_plane_name: string;
network_id: string;
name: string;
dns_domain?: string | null;
overlay_v4?: string | null;
overlay_v6?: string | null;
requires_approval: boolean;
created_at: string;
updated_at: string;
};
export type RouteInfo = {
prefix: string;
kind: string;
enabled: boolean;
mapped_prefix?: string | null;
};
export type NodeInfo = {
id: string;
name: string;
dns_name: string;
ipv4: string;
ipv6: string;
wg_public_key: string;
machine_public_key: string;
endpoints: string[];
tags: string[];
routes: RouteInfo[];
last_seen: number;
approved: boolean;
key_rotation_required?: boolean;
revoked?: boolean;
};
export type AdminNodesResponse = {
nodes: NodeInfo[];
};
export type EnrollmentToken = {
token: string;
expires_at: number;
uses_left: number;
tags: string[];
revoked_at?: number | null;
};
export type CreateNetworkResult = {
network: NetworkSummary;
bootstrap_token?: EnrollmentToken | null;
};
export type CreateTokenResponse = {
token: EnrollmentToken;
};
export type KeyPolicyResponse = {
policy: {
max_age_seconds?: number | null;
};
};
export type KeyHistoryResponse = {
node_id: string;
keys: {
key_type: string;
public_key: string;
created_at: number;
revoked_at?: number | null;
}[];
};
export type ApproveNodeResponse = {
node_id: string;
approved: boolean;
approved_at?: number | null;
};
export type RevokeNodeResponse = {
node_id: string;
revoked_at?: number | null;
};
export type KeyRotationResponse = {
node_id: string;
machine_public_key: string;
wg_public_key: string;
};
export type AuditEntry = {
id: string;
network_id?: string | null;
node_id?: string | null;
action: string;
timestamp: number;
detail?: unknown;
};
export type AuditLogResponse = {
entries: AuditEntry[];
};
export type AdminAuditEntry = {
id: string;
actor_user_id?: string | null;
actor_email?: string | null;
action: string;
target_type?: string | null;
target_id?: string | null;
metadata?: unknown;
created_at: string;
};
export type ProviderSummary = {
id: string;
name: string;
};
export async function getMe() {
return request<UserSummary>('/auth/me');
}
export async function login(email: string, password: string) {
return request<{ user: UserSummary }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
}
export async function logout() {
return request<{ ok: boolean }>('/auth/logout', { method: 'POST' });
}
export async function listProviders() {
return request<ProviderSummary[]>('/auth/providers');
}
export async function listControlPlanes() {
return request<ControlPlaneSummary[]>('/control-planes');
}
export async function createControlPlane(payload: {
name: string;
base_url: string;
admin_token?: string;
region?: string;
}) {
return request<ControlPlaneSummary>('/control-planes', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function updateControlPlane(id: string, payload: {
name?: string;
base_url?: string;
admin_token?: string;
clear_admin_token?: boolean;
region?: string;
}) {
return request<ControlPlaneSummary>(`/control-planes/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export async function verifyControlPlane(id: string) {
return request<{ ok: boolean; status?: number; body?: string }>(
`/control-planes/${id}/verify`,
{ method: 'POST' },
);
}
export async function listNetworks() {
return request<NetworkSummary[]>('/networks');
}
export async function createNetwork(payload: {
control_plane_id: string;
name: string;
dns_domain?: string;
requires_approval?: boolean;
key_rotation_max_age_seconds?: number;
bootstrap_token_ttl_seconds?: number;
bootstrap_token_uses?: number;
bootstrap_token_tags?: string[];
}) {
return request<CreateNetworkResult>('/networks', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function listNodes(networkId: string) {
return request<AdminNodesResponse>(`/networks/${networkId}/nodes`);
}
export async function approveNode(networkId: string, nodeId: string) {
return request<ApproveNodeResponse>(`/networks/${networkId}/nodes/${nodeId}/approve`, {
method: 'POST',
});
}
export async function revokeNode(networkId: string, nodeId: string) {
return request<RevokeNodeResponse>(`/networks/${networkId}/nodes/${nodeId}/revoke`, {
method: 'POST',
});
}
export async function rotateNodeKeys(
networkId: string,
nodeId: string,
payload: { machine_public_key?: string; wg_public_key?: string },
) {
return request<KeyRotationResponse>(`/networks/${networkId}/nodes/${nodeId}/rotate-keys`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function nodeKeys(networkId: string, nodeId: string) {
return request<KeyHistoryResponse>(`/networks/${networkId}/nodes/${nodeId}/keys`);
}
export async function createToken(networkId: string, payload: {
ttl_seconds: number;
uses: number;
tags: string[];
}) {
return request<CreateTokenResponse>(`/networks/${networkId}/tokens`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function revokeToken(networkId: string, token_id: string) {
return request<EnrollmentToken>(`/networks/${networkId}/tokens/revoke`, {
method: 'POST',
body: JSON.stringify({ token_id }),
});
}
export async function getAcl(networkId: string) {
return request<unknown>(`/networks/${networkId}/acl`);
}
export async function updateAcl(networkId: string, policy: unknown) {
return request<unknown>(`/networks/${networkId}/acl`, {
method: 'PUT',
body: JSON.stringify({ policy }),
});
}
export async function getKeyPolicy(networkId: string) {
return request<KeyPolicyResponse>(`/networks/${networkId}/key-policy`);
}
export async function updateKeyPolicy(networkId: string, payload: { max_age_seconds?: number | null }) {
return request<KeyPolicyResponse>(`/networks/${networkId}/key-policy`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export async function listUsers() {
return request<UserSummary[]>('/users');
}
export async function getUser(userId: string) {
return request<UserDetail>(`/users/${userId}`);
}
export async function createUser(payload: {
email: string;
display_name?: string;
password: string;
role_id?: string;
super_admin?: boolean;
}) {
return request<UserDetail>('/users', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function updateUser(userId: string, payload: {
display_name?: string | null;
disabled?: boolean;
super_admin?: boolean;
}) {
return request<UserSummary>(`/users/${userId}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export async function setUserPassword(userId: string, password: string) {
return request<{ ok: boolean }>(`/users/${userId}/password`, {
method: 'POST',
body: JSON.stringify({ password }),
});
}
export async function addMembership(userId: string, payload: {
role_id: string;
scope_type: string;
scope_id: string;
}) {
return request<MembershipSummary>(`/users/${userId}/memberships`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function deleteMembership(userId: string, membershipId: string) {
return request<{ ok: boolean }>(`/users/${userId}/memberships/${membershipId}`, {
method: 'DELETE',
});
}
export async function listRoles() {
return request<RoleSummary[]>('/users/roles');
}
export async function listAdminAudit(params: {
actor_user_id?: string;
action?: string;
limit?: number;
}) {
const search = new URLSearchParams();
if (params.actor_user_id) search.set('actor_user_id', params.actor_user_id);
if (params.action) search.set('action', params.action);
if (params.limit) search.set('limit', String(params.limit));
const query = search.toString();
return request<AdminAuditEntry[]>(`/audit${query ? `?${query}` : ''}`);
}
export async function listControlPlaneAudit(
controlPlaneId: string,
params: { network_id?: string; node_id?: string; limit?: number },
) {
const search = new URLSearchParams();
if (params.network_id) search.set('network_id', params.network_id);
if (params.node_id) search.set('node_id', params.node_id);
if (params.limit) search.set('limit', String(params.limit));
const query = search.toString();
return request<AuditLogResponse>(
`/audit/control-planes/${controlPlaneId}${query ? `?${query}` : ''}`,
);
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4 KiB

60
frontend/src/index.css Normal file
View file

@ -0,0 +1,60 @@
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;600&family=Space+Grotesk:wght@400;500;600;700&display=swap');
:root {
--font-display: 'Space Grotesk', 'IBM Plex Sans', sans-serif;
--font-body: 'IBM Plex Sans', sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
--bg: #f4f1e9;
--bg-deep: #ebe4d6;
--surface: rgba(255, 255, 255, 0.92);
--ink: #1f1b16;
--ink-soft: #5b5347;
--accent: #f25c2a;
--accent-2: #2b9a8f;
--accent-3: #f6c453;
--border: #d9cdbd;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: var(--font-body);
color: var(--ink);
background:
radial-gradient(circle at 20% 20%, rgba(246, 196, 83, 0.35), transparent 40%),
radial-gradient(circle at 80% 0%, rgba(43, 154, 143, 0.22), transparent 45%),
linear-gradient(120deg, var(--bg), var(--bg-deep));
min-height: 100vh;
}
a {
color: inherit;
text-decoration: none;
}
p {
margin: 0;
}
h1,
h2,
h3,
h4 {
margin: 0;
font-family: var(--font-display);
}
button,
input,
select,
textarea {
font-family: inherit;
}
::selection {
background: rgba(242, 92, 42, 0.2);
}

10
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View file

@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

7
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})