Implement user-bound join flows and add admin image build pipeline
Some checks failed
build-local-image / build (push) Has been cancelled
Some checks failed
build-local-image / build (push) Has been cancelled
This commit is contained in:
parent
6ae8b6e898
commit
98eb7057a5
16 changed files with 1000 additions and 113 deletions
8
.dockerignore
Normal file
8
.dockerignore
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
target
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/dist
|
||||||
|
backend/target
|
||||||
|
Dockerfile*
|
||||||
|
.forgejo
|
||||||
20
.forgejo/workflows/build-local-image.yml
Normal file
20
.forgejo/workflows/build-local-image.yml
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
name: build-local-image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: nix-host
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build local image on runner host
|
||||||
|
run: docker build --pull -t lightscale-admin:local .
|
||||||
|
|
||||||
|
- name: Show built image
|
||||||
|
run: docker image ls lightscale-admin:local
|
||||||
34
Dockerfile
Normal file
34
Dockerfile
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
|
FROM node:22-bookworm-slim AS frontend-build
|
||||||
|
WORKDIR /src/frontend
|
||||||
|
COPY frontend/package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM rust:1.88-bookworm AS backend-build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY backend ./backend
|
||||||
|
RUN cargo build --release -p lightscale-admin-server
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim AS runtime
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN useradd --system --create-home --uid 10001 lightscale
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=backend-build /src/target/release/lightscale-admin-server /usr/local/bin/lightscale-admin-server
|
||||||
|
COPY --from=frontend-build /src/frontend/dist /app/frontend/dist
|
||||||
|
|
||||||
|
USER lightscale
|
||||||
|
EXPOSE 8081
|
||||||
|
|
||||||
|
ENV RUST_LOG=info
|
||||||
|
ENV LS_ADMIN__SERVER__BIND=0.0.0.0:8081
|
||||||
|
ENV LS_ADMIN__SERVER__STATIC_DIR=/app/frontend/dist
|
||||||
|
|
||||||
|
CMD ["/usr/local/bin/lightscale-admin-server"]
|
||||||
32
README.md
32
README.md
|
|
@ -6,6 +6,37 @@ A thin admin control plane for Lightscale. It stores operator metadata in Cockro
|
||||||
- `backend/`: Rust (Axum) API server, `/admin/api` namespace.
|
- `backend/`: Rust (Axum) API server, `/admin/api` namespace.
|
||||||
- `frontend/`: Vite React SPA.
|
- `frontend/`: Vite React SPA.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- Local account authentication
|
||||||
|
- OIDC authentication
|
||||||
|
- Session management
|
||||||
|
- Bootstrap admin creation
|
||||||
|
|
||||||
|
### RBAC
|
||||||
|
- Role-based access control
|
||||||
|
- 5 default roles (Owner, Admin, Viewer, Member, Joiner)
|
||||||
|
- Membership management
|
||||||
|
- `console:access` permission gate for admin console/API access
|
||||||
|
- `join_tokens:create` permission for non-console self-service device enrollment
|
||||||
|
|
||||||
|
### Control Plane Management
|
||||||
|
- CRUD operations (Create, Read, Update, Delete)
|
||||||
|
- Health check/verification
|
||||||
|
|
||||||
|
### Network Management
|
||||||
|
- Create/Delete/List networks
|
||||||
|
- Node management (approve, revoke, key rotation)
|
||||||
|
- Token management (create, revoke)
|
||||||
|
- Self-service join token APIs (`/admin/api/auth/join-networks`, `/admin/api/auth/join-token`)
|
||||||
|
- ACL configuration
|
||||||
|
- Key policy configuration
|
||||||
|
|
||||||
|
### Audit
|
||||||
|
- Admin audit log
|
||||||
|
- Control Plane audit log
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
1) Start CockroachDB (single node for local dev):
|
1) Start CockroachDB (single node for local dev):
|
||||||
|
|
||||||
|
|
@ -45,6 +76,7 @@ Key settings:
|
||||||
- `auth.bootstrap_admin_email` / `auth.bootstrap_admin_password`: creates the first admin if the database is empty.
|
- `auth.bootstrap_admin_email` / `auth.bootstrap_admin_password`: creates the first admin if the database is empty.
|
||||||
- `server.allowed_origins`: set when the UI is hosted separately (CORS + cookies).
|
- `server.allowed_origins`: set when the UI is hosted separately (CORS + cookies).
|
||||||
- `server.static_dir`: serve the SPA from this folder (usually `../frontend/dist`).
|
- `server.static_dir`: serve the SPA from this folder (usually `../frontend/dist`).
|
||||||
|
- `database.disable_migration_locking`: optional override to disable SQLx migration advisory locks (`LS_ADMIN__DATABASE__DISABLE_MIGRATION_LOCKING=true`). CockroachDB is auto-detected.
|
||||||
|
|
||||||
## Control planes
|
## Control planes
|
||||||
Create control planes in the UI and store their admin tokens. The admin API will call each control plane’s `/v1/*` endpoints to manage networks and nodes.
|
Create control planes in the UI and store their admin tokens. The admin API will call each control plane’s `/v1/*` endpoints to manage networks and nodes.
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,9 @@ INSERT INTO roles (id, name, description, created_at)
|
||||||
VALUES
|
VALUES
|
||||||
('00000000-0000-0000-0000-000000000001', 'owner', 'Full access', now()),
|
('00000000-0000-0000-0000-000000000001', 'owner', 'Full access', now()),
|
||||||
('00000000-0000-0000-0000-000000000002', 'admin', 'Network admin access', now()),
|
('00000000-0000-0000-0000-000000000002', 'admin', 'Network admin access', now()),
|
||||||
('00000000-0000-0000-0000-000000000003', 'viewer', 'Read-only access', now());
|
('00000000-0000-0000-0000-000000000003', 'viewer', 'Read-only access', now()),
|
||||||
|
('00000000-0000-0000-0000-000000000004', 'member', 'Authenticated user without console access', now()),
|
||||||
|
('00000000-0000-0000-0000-000000000005', 'joiner', 'Can mint user-bound join tokens (typically network-scoped)', now());
|
||||||
|
|
||||||
INSERT INTO role_permissions (role_id, permission, created_at)
|
INSERT INTO role_permissions (role_id, permission, created_at)
|
||||||
VALUES
|
VALUES
|
||||||
|
|
@ -124,6 +126,7 @@ VALUES
|
||||||
('00000000-0000-0000-0000-000000000001', 'nodes:read', now()),
|
('00000000-0000-0000-0000-000000000001', 'nodes:read', now()),
|
||||||
('00000000-0000-0000-0000-000000000001', 'nodes:write', now()),
|
('00000000-0000-0000-0000-000000000001', 'nodes:write', now()),
|
||||||
('00000000-0000-0000-0000-000000000001', 'tokens:write', now()),
|
('00000000-0000-0000-0000-000000000001', 'tokens:write', now()),
|
||||||
|
('00000000-0000-0000-0000-000000000001', 'join_tokens:create', now()),
|
||||||
('00000000-0000-0000-0000-000000000001', 'acl:read', now()),
|
('00000000-0000-0000-0000-000000000001', 'acl:read', now()),
|
||||||
('00000000-0000-0000-0000-000000000001', 'acl:write', now()),
|
('00000000-0000-0000-0000-000000000001', 'acl:write', now()),
|
||||||
('00000000-0000-0000-0000-000000000001', 'key_policy:read', now()),
|
('00000000-0000-0000-0000-000000000001', 'key_policy:read', now()),
|
||||||
|
|
@ -133,6 +136,7 @@ VALUES
|
||||||
('00000000-0000-0000-0000-000000000001', 'users:write', now()),
|
('00000000-0000-0000-0000-000000000001', 'users:write', now()),
|
||||||
('00000000-0000-0000-0000-000000000001', 'roles:read', now()),
|
('00000000-0000-0000-0000-000000000001', 'roles:read', now()),
|
||||||
('00000000-0000-0000-0000-000000000001', 'roles:write', now()),
|
('00000000-0000-0000-0000-000000000001', 'roles:write', now()),
|
||||||
|
('00000000-0000-0000-0000-000000000001', 'console:access', now()),
|
||||||
|
|
||||||
('00000000-0000-0000-0000-000000000002', 'control_planes:read', now()),
|
('00000000-0000-0000-0000-000000000002', 'control_planes:read', now()),
|
||||||
('00000000-0000-0000-0000-000000000002', 'networks:read', now()),
|
('00000000-0000-0000-0000-000000000002', 'networks:read', now()),
|
||||||
|
|
@ -140,6 +144,7 @@ VALUES
|
||||||
('00000000-0000-0000-0000-000000000002', 'nodes:read', now()),
|
('00000000-0000-0000-0000-000000000002', 'nodes:read', now()),
|
||||||
('00000000-0000-0000-0000-000000000002', 'nodes:write', now()),
|
('00000000-0000-0000-0000-000000000002', 'nodes:write', now()),
|
||||||
('00000000-0000-0000-0000-000000000002', 'tokens:write', now()),
|
('00000000-0000-0000-0000-000000000002', 'tokens:write', now()),
|
||||||
|
('00000000-0000-0000-0000-000000000002', 'join_tokens:create', now()),
|
||||||
('00000000-0000-0000-0000-000000000002', 'acl:read', now()),
|
('00000000-0000-0000-0000-000000000002', 'acl:read', now()),
|
||||||
('00000000-0000-0000-0000-000000000002', 'acl:write', now()),
|
('00000000-0000-0000-0000-000000000002', 'acl:write', now()),
|
||||||
('00000000-0000-0000-0000-000000000002', 'key_policy:read', now()),
|
('00000000-0000-0000-0000-000000000002', 'key_policy:read', now()),
|
||||||
|
|
@ -147,9 +152,13 @@ VALUES
|
||||||
('00000000-0000-0000-0000-000000000002', 'audit:read', now()),
|
('00000000-0000-0000-0000-000000000002', 'audit:read', now()),
|
||||||
('00000000-0000-0000-0000-000000000002', 'users:read', now()),
|
('00000000-0000-0000-0000-000000000002', 'users:read', now()),
|
||||||
('00000000-0000-0000-0000-000000000002', 'roles:read', now()),
|
('00000000-0000-0000-0000-000000000002', 'roles:read', now()),
|
||||||
|
('00000000-0000-0000-0000-000000000002', 'console:access', now()),
|
||||||
|
|
||||||
('00000000-0000-0000-0000-000000000003', 'control_planes:read', now()),
|
('00000000-0000-0000-0000-000000000003', 'control_planes:read', now()),
|
||||||
('00000000-0000-0000-0000-000000000003', 'networks:read', now()),
|
('00000000-0000-0000-0000-000000000003', 'networks:read', now()),
|
||||||
('00000000-0000-0000-0000-000000000003', 'nodes:read', now()),
|
('00000000-0000-0000-0000-000000000003', 'nodes:read', now()),
|
||||||
('00000000-0000-0000-0000-000000000003', 'audit:read', now()),
|
('00000000-0000-0000-0000-000000000003', 'audit:read', now()),
|
||||||
('00000000-0000-0000-0000-000000000003', 'roles:read', now());
|
('00000000-0000-0000-0000-000000000003', 'roles:read', now()),
|
||||||
|
('00000000-0000-0000-0000-000000000003', 'console:access', now()),
|
||||||
|
|
||||||
|
('00000000-0000-0000-0000-000000000005', 'join_tokens:create', now());
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,16 @@
|
||||||
use crate::api::{ApiError, AuthUser};
|
use crate::api::ApiError;
|
||||||
use crate::app_state::AppState;
|
use crate::app_state::AppState;
|
||||||
use crate::auth::{create_session, delete_session, hash_password, verify_password, SESSION_COOKIE};
|
use crate::audit_log;
|
||||||
|
use crate::auth::{
|
||||||
|
create_session, delete_session, hash_password, session_user, verify_password, SESSION_COOKIE,
|
||||||
|
};
|
||||||
|
use crate::control_plane::{CreateTokenRequest as CpCreateTokenRequest, EnrollmentToken};
|
||||||
use crate::models::User;
|
use crate::models::User;
|
||||||
use crate::oidc::{build_auth_request, exchange_code};
|
use crate::oidc::{build_auth_request, exchange_code};
|
||||||
|
use crate::permissions::{
|
||||||
|
ensure_permission, list_permissions_global, PERM_CONSOLE_ACCESS, PERM_JOIN_TOKENS_CREATE,
|
||||||
|
PERM_TOKENS_WRITE,
|
||||||
|
};
|
||||||
use axum::extract::{Path, Query, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::response::Redirect;
|
use axum::response::Redirect;
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
|
|
@ -16,6 +24,12 @@ use sqlx::{FromRow, PgPool};
|
||||||
use time::{Duration, OffsetDateTime};
|
use time::{Duration, OffsetDateTime};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const JOIN_SCOPE_TYPE_NETWORK: &str = "network";
|
||||||
|
const DEFAULT_JOIN_TOKEN_TTL_SECONDS: u64 = 600;
|
||||||
|
const MAX_JOIN_TOKEN_TTL_SECONDS: u64 = 86_400;
|
||||||
|
const DEFAULT_JOIN_TOKEN_USES: u32 = 1;
|
||||||
|
const MAX_JOIN_TOKEN_USES: u32 = 5;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct LoginRequest {
|
struct LoginRequest {
|
||||||
email: String,
|
email: String,
|
||||||
|
|
@ -25,6 +39,8 @@ struct LoginRequest {
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct LoginResponse {
|
struct LoginResponse {
|
||||||
user: UserSummary,
|
user: UserSummary,
|
||||||
|
permissions: Vec<String>,
|
||||||
|
console_access: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
|
|
@ -61,11 +77,55 @@ struct OidcStateRow {
|
||||||
expires_at: OffsetDateTime,
|
expires_at: OffsetDateTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct CreateJoinTokenRequest {
|
||||||
|
network_id: Uuid,
|
||||||
|
ttl_seconds: Option<u64>,
|
||||||
|
uses: Option<u32>,
|
||||||
|
tags: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct CreateJoinTokenResponse {
|
||||||
|
network: JoinableNetwork,
|
||||||
|
token: EnrollmentToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct JoinableNetwork {
|
||||||
|
id: Uuid,
|
||||||
|
control_plane_id: Uuid,
|
||||||
|
control_plane_name: String,
|
||||||
|
network_id: String,
|
||||||
|
name: String,
|
||||||
|
dns_domain: Option<String>,
|
||||||
|
overlay_v4: Option<String>,
|
||||||
|
overlay_v6: Option<String>,
|
||||||
|
requires_approval: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, FromRow)]
|
||||||
|
struct JoinableNetworkRow {
|
||||||
|
id: Uuid,
|
||||||
|
control_plane_id: Uuid,
|
||||||
|
control_plane_name: String,
|
||||||
|
network_id: String,
|
||||||
|
name: String,
|
||||||
|
dns_domain: Option<String>,
|
||||||
|
overlay_v4: Option<String>,
|
||||||
|
overlay_v6: Option<String>,
|
||||||
|
requires_approval: bool,
|
||||||
|
base_url: String,
|
||||||
|
admin_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/login", post(login))
|
.route("/login", post(login))
|
||||||
.route("/logout", post(logout))
|
.route("/logout", post(logout))
|
||||||
.route("/me", get(me))
|
.route("/me", get(me))
|
||||||
|
.route("/join-networks", get(join_networks))
|
||||||
|
.route("/join-token", post(create_join_token))
|
||||||
.route("/providers", get(list_providers))
|
.route("/providers", get(list_providers))
|
||||||
.route("/oidc/:provider/login", get(oidc_login))
|
.route("/oidc/:provider/login", get(oidc_login))
|
||||||
.route("/oidc/:provider/callback", get(oidc_callback))
|
.route("/oidc/:provider/callback", get(oidc_callback))
|
||||||
|
|
@ -109,17 +169,8 @@ async fn login(
|
||||||
let cookie = build_cookie(&state, token);
|
let cookie = build_cookie(&state, token);
|
||||||
let jar = jar.add(cookie);
|
let jar = jar.add(cookie);
|
||||||
|
|
||||||
Ok((
|
let response = auth_response(&state, user).await?;
|
||||||
jar,
|
Ok((jar, Json(response)))
|
||||||
Json(LoginResponse {
|
|
||||||
user: UserSummary {
|
|
||||||
id: user.id,
|
|
||||||
email: user.email,
|
|
||||||
display_name: user.display_name,
|
|
||||||
super_admin: user.super_admin,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn logout(
|
async fn logout(
|
||||||
|
|
@ -136,12 +187,79 @@ async fn logout(
|
||||||
Ok((jar, Json(json!({ "ok": true }))))
|
Ok((jar, Json(json!({ "ok": true }))))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn me(AuthUser { user }: AuthUser) -> Result<Json<UserSummary>, ApiError> {
|
async fn me(
|
||||||
Ok(Json(UserSummary {
|
State(state): State<AppState>,
|
||||||
id: user.id,
|
jar: CookieJar,
|
||||||
email: user.email,
|
) -> Result<Json<LoginResponse>, ApiError> {
|
||||||
display_name: user.display_name,
|
let user = authenticated_user(&state, &jar).await?;
|
||||||
super_admin: user.super_admin,
|
let response = auth_response(&state, user).await?;
|
||||||
|
Ok(Json(response))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn join_networks(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
) -> Result<Json<Vec<JoinableNetwork>>, ApiError> {
|
||||||
|
let user = authenticated_user(&state, &jar).await?;
|
||||||
|
let networks = fetch_joinable_networks(&state.pool, &user).await?;
|
||||||
|
Ok(Json(
|
||||||
|
networks
|
||||||
|
.into_iter()
|
||||||
|
.map(JoinableNetwork::from_row)
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_join_token(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Json(req): Json<CreateJoinTokenRequest>,
|
||||||
|
) -> Result<Json<CreateJoinTokenResponse>, ApiError> {
|
||||||
|
let user = authenticated_user(&state, &jar).await?;
|
||||||
|
let network = fetch_network_for_join(&state.pool, req.network_id).await?;
|
||||||
|
ensure_join_token_permission(&state.pool, &user, network.id).await?;
|
||||||
|
|
||||||
|
let ttl_seconds = normalize_join_token_ttl(req.ttl_seconds)?;
|
||||||
|
let uses = normalize_join_token_uses(req.uses)?;
|
||||||
|
let tags = req.tags.unwrap_or_default();
|
||||||
|
|
||||||
|
let client = crate::control_plane::ControlPlaneClient::new(
|
||||||
|
network.base_url.clone(),
|
||||||
|
network.admin_token.clone(),
|
||||||
|
);
|
||||||
|
let response = client
|
||||||
|
.create_token(
|
||||||
|
&network.network_id,
|
||||||
|
CpCreateTokenRequest {
|
||||||
|
ttl_seconds,
|
||||||
|
uses,
|
||||||
|
tags,
|
||||||
|
owner_user_id: Some(user.id.to_string()),
|
||||||
|
owner_email: Some(user.email.clone()),
|
||||||
|
owner_is_admin: Some(false),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::BadRequest("control plane request failed".to_string()))?;
|
||||||
|
|
||||||
|
audit_log::record(
|
||||||
|
&state.pool,
|
||||||
|
Some(user.id),
|
||||||
|
"token.create_self_service",
|
||||||
|
Some("network"),
|
||||||
|
Some(&network.id.to_string()),
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"network_id": network.network_id,
|
||||||
|
"owner_user_id": user.id,
|
||||||
|
"owner_email": user.email,
|
||||||
|
"owner_is_admin": false,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(CreateJoinTokenResponse {
|
||||||
|
network: JoinableNetwork::from_row(network),
|
||||||
|
token: response.token,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -327,7 +445,7 @@ async fn ensure_oidc_user(
|
||||||
.map_err(|_| ApiError::Internal)?;
|
.map_err(|_| ApiError::Internal)?;
|
||||||
|
|
||||||
let role_id = sqlx::query_scalar::<_, Uuid>(
|
let role_id = sqlx::query_scalar::<_, Uuid>(
|
||||||
"SELECT id FROM roles WHERE name = 'viewer'",
|
"SELECT id FROM roles WHERE name = 'member'",
|
||||||
)
|
)
|
||||||
.fetch_one(&mut *tx)
|
.fetch_one(&mut *tx)
|
||||||
.await
|
.await
|
||||||
|
|
@ -363,6 +481,24 @@ async fn ensure_oidc_user(
|
||||||
Ok(user)
|
Ok(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn auth_response(state: &AppState, user: User) -> Result<LoginResponse, ApiError> {
|
||||||
|
let permissions = list_permissions_global(&state.pool, &user).await?;
|
||||||
|
let console_access = user.super_admin
|
||||||
|
|| permissions
|
||||||
|
.iter()
|
||||||
|
.any(|permission| permission == PERM_CONSOLE_ACCESS);
|
||||||
|
Ok(LoginResponse {
|
||||||
|
user: UserSummary {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
display_name: user.display_name,
|
||||||
|
super_admin: user.super_admin,
|
||||||
|
},
|
||||||
|
permissions,
|
||||||
|
console_access,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn build_cookie(state: &AppState, token: String) -> Cookie<'static> {
|
fn build_cookie(state: &AppState, token: String) -> Cookie<'static> {
|
||||||
let mut cookie = Cookie::build((SESSION_COOKIE, token))
|
let mut cookie = Cookie::build((SESSION_COOKIE, token))
|
||||||
.http_only(true)
|
.http_only(true)
|
||||||
|
|
@ -444,3 +580,165 @@ pub async fn ensure_bootstrap_admin(pool: &PgPool, config: &crate::config::Confi
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl JoinableNetwork {
|
||||||
|
fn from_row(row: JoinableNetworkRow) -> Self {
|
||||||
|
Self {
|
||||||
|
id: row.id,
|
||||||
|
control_plane_id: row.control_plane_id,
|
||||||
|
control_plane_name: row.control_plane_name,
|
||||||
|
network_id: row.network_id,
|
||||||
|
name: row.name,
|
||||||
|
dns_domain: row.dns_domain,
|
||||||
|
overlay_v4: row.overlay_v4,
|
||||||
|
overlay_v6: row.overlay_v6,
|
||||||
|
requires_approval: row.requires_approval,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn authenticated_user(state: &AppState, jar: &CookieJar) -> Result<User, ApiError> {
|
||||||
|
let token = jar
|
||||||
|
.get(SESSION_COOKIE)
|
||||||
|
.map(Cookie::value)
|
||||||
|
.ok_or(ApiError::Unauthorized)?;
|
||||||
|
let user = session_user(&state.pool, token)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::Internal)?
|
||||||
|
.ok_or(ApiError::Unauthorized)?;
|
||||||
|
if user.disabled {
|
||||||
|
return Err(ApiError::Unauthorized);
|
||||||
|
}
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_joinable_networks(pool: &PgPool, user: &User) -> Result<Vec<JoinableNetworkRow>, ApiError> {
|
||||||
|
if user.super_admin {
|
||||||
|
return sqlx::query_as::<_, JoinableNetworkRow>(
|
||||||
|
r#"
|
||||||
|
SELECT n.id,
|
||||||
|
n.control_plane_id,
|
||||||
|
c.name AS control_plane_name,
|
||||||
|
n.network_id,
|
||||||
|
n.name,
|
||||||
|
n.dns_domain,
|
||||||
|
n.overlay_v4,
|
||||||
|
n.overlay_v6,
|
||||||
|
n.requires_approval,
|
||||||
|
c.base_url,
|
||||||
|
c.admin_token
|
||||||
|
FROM networks n
|
||||||
|
JOIN control_planes c ON c.id = n.control_plane_id
|
||||||
|
ORDER BY n.created_at DESC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::Internal);
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query_as::<_, JoinableNetworkRow>(
|
||||||
|
r#"
|
||||||
|
SELECT n.id,
|
||||||
|
n.control_plane_id,
|
||||||
|
c.name AS control_plane_name,
|
||||||
|
n.network_id,
|
||||||
|
n.name,
|
||||||
|
n.dns_domain,
|
||||||
|
n.overlay_v4,
|
||||||
|
n.overlay_v6,
|
||||||
|
n.requires_approval,
|
||||||
|
c.base_url,
|
||||||
|
c.admin_token
|
||||||
|
FROM networks n
|
||||||
|
JOIN control_planes c ON c.id = n.control_plane_id
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM memberships m
|
||||||
|
JOIN role_permissions rp ON rp.role_id = m.role_id
|
||||||
|
WHERE m.user_id = $1
|
||||||
|
AND (rp.permission = $2 OR rp.permission = $3)
|
||||||
|
AND (
|
||||||
|
(m.scope_type = 'global' AND m.scope_id = 'global')
|
||||||
|
OR (m.scope_type = $4 AND m.scope_id = n.id::text)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ORDER BY n.created_at DESC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user.id)
|
||||||
|
.bind(PERM_JOIN_TOKENS_CREATE)
|
||||||
|
.bind(PERM_TOKENS_WRITE)
|
||||||
|
.bind(JOIN_SCOPE_TYPE_NETWORK)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::Internal)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_network_for_join(pool: &PgPool, id: Uuid) -> Result<JoinableNetworkRow, ApiError> {
|
||||||
|
sqlx::query_as::<_, JoinableNetworkRow>(
|
||||||
|
r#"
|
||||||
|
SELECT n.id,
|
||||||
|
n.control_plane_id,
|
||||||
|
c.name AS control_plane_name,
|
||||||
|
n.network_id,
|
||||||
|
n.name,
|
||||||
|
n.dns_domain,
|
||||||
|
n.overlay_v4,
|
||||||
|
n.overlay_v6,
|
||||||
|
n.requires_approval,
|
||||||
|
c.base_url,
|
||||||
|
c.admin_token
|
||||||
|
FROM networks n
|
||||||
|
JOIN control_planes c ON c.id = n.control_plane_id
|
||||||
|
WHERE n.id = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::Internal)?
|
||||||
|
.ok_or(ApiError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_join_token_permission(pool: &PgPool, user: &User, network_id: Uuid) -> Result<(), ApiError> {
|
||||||
|
let scope_id = network_id.to_string();
|
||||||
|
match ensure_permission(
|
||||||
|
pool,
|
||||||
|
user,
|
||||||
|
PERM_JOIN_TOKENS_CREATE,
|
||||||
|
JOIN_SCOPE_TYPE_NETWORK,
|
||||||
|
&scope_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(ApiError::Forbidden) => {
|
||||||
|
ensure_permission(pool, user, PERM_TOKENS_WRITE, JOIN_SCOPE_TYPE_NETWORK, &scope_id)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Err(err) => Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_join_token_ttl(value: Option<u64>) -> Result<u64, ApiError> {
|
||||||
|
let ttl = value.unwrap_or(DEFAULT_JOIN_TOKEN_TTL_SECONDS);
|
||||||
|
if ttl == 0 || ttl > MAX_JOIN_TOKEN_TTL_SECONDS {
|
||||||
|
return Err(ApiError::BadRequest(format!(
|
||||||
|
"ttl_seconds must be between 1 and {}",
|
||||||
|
MAX_JOIN_TOKEN_TTL_SECONDS
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_join_token_uses(value: Option<u32>) -> Result<u32, ApiError> {
|
||||||
|
let uses = value.unwrap_or(DEFAULT_JOIN_TOKEN_USES);
|
||||||
|
if uses == 0 || uses > MAX_JOIN_TOKEN_USES {
|
||||||
|
return Err(ApiError::BadRequest(format!(
|
||||||
|
"uses must be between 1 and {}",
|
||||||
|
MAX_JOIN_TOKEN_USES
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(uses)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ pub mod audit;
|
||||||
use crate::app_state::AppState;
|
use crate::app_state::AppState;
|
||||||
use crate::auth::{session_user, SESSION_COOKIE};
|
use crate::auth::{session_user, SESSION_COOKIE};
|
||||||
use crate::models::User;
|
use crate::models::User;
|
||||||
|
use crate::permissions::{ensure_permission_global, PERM_CONSOLE_ACCESS};
|
||||||
use axum::extract::FromRequestParts;
|
use axum::extract::FromRequestParts;
|
||||||
use axum::http::{request::Parts, StatusCode};
|
use axum::http::{request::Parts, StatusCode};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
|
|
@ -70,6 +71,7 @@ impl FromRequestParts<AppState> for AuthUser {
|
||||||
if user.disabled {
|
if user.disabled {
|
||||||
return Err(ApiError::Unauthorized);
|
return Err(ApiError::Unauthorized);
|
||||||
}
|
}
|
||||||
|
ensure_permission_global(&state.pool, &user, PERM_CONSOLE_ACCESS).await?;
|
||||||
Ok(Self { user })
|
Ok(Self { user })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ use crate::permissions::{
|
||||||
PERM_NODES_WRITE, PERM_TOKENS_WRITE, PERM_AUDIT_READ,
|
PERM_NODES_WRITE, PERM_TOKENS_WRITE, PERM_AUDIT_READ,
|
||||||
};
|
};
|
||||||
use axum::extract::{Path, Query, State};
|
use axum::extract::{Path, Query, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
@ -40,6 +41,8 @@ struct NetworkSummary {
|
||||||
struct CreateNetworkRequest {
|
struct CreateNetworkRequest {
|
||||||
control_plane_id: Uuid,
|
control_plane_id: Uuid,
|
||||||
name: String,
|
name: String,
|
||||||
|
overlay_v4: Option<String>,
|
||||||
|
overlay_v6: Option<String>,
|
||||||
dns_domain: Option<String>,
|
dns_domain: Option<String>,
|
||||||
requires_approval: Option<bool>,
|
requires_approval: Option<bool>,
|
||||||
key_rotation_max_age_seconds: Option<u64>,
|
key_rotation_max_age_seconds: Option<u64>,
|
||||||
|
|
@ -86,7 +89,7 @@ struct NetworkAuditQuery {
|
||||||
pub fn router() -> Router<crate::app_state::AppState> {
|
pub fn router() -> Router<crate::app_state::AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(list_networks).post(create_network))
|
.route("/", get(list_networks).post(create_network))
|
||||||
.route("/:id", get(get_network))
|
.route("/:id", get(get_network).delete(delete_network))
|
||||||
.route("/:id/tokens", post(create_token))
|
.route("/:id/tokens", post(create_token))
|
||||||
.route("/:id/tokens/revoke", post(revoke_token))
|
.route("/:id/tokens/revoke", post(revoke_token))
|
||||||
.route("/:id/nodes", get(list_nodes))
|
.route("/:id/nodes", get(list_nodes))
|
||||||
|
|
@ -168,6 +171,44 @@ async fn get_network(
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn delete_network(
|
||||||
|
State(state): State<crate::app_state::AppState>,
|
||||||
|
AuthUser { user }: AuthUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
ensure_permission_global(&state.pool, &user, PERM_NETWORKS_WRITE).await?;
|
||||||
|
let (network, control_plane) = fetch_network_with_cp(&state.pool, id).await?;
|
||||||
|
let client = client_for(&control_plane);
|
||||||
|
|
||||||
|
// Send delete request to control plane
|
||||||
|
client
|
||||||
|
.delete_network(&network.network_id)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::BadRequest("control plane request failed".to_string()))?;
|
||||||
|
|
||||||
|
// Delete from database
|
||||||
|
sqlx::query("DELETE FROM networks WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::Internal)?;
|
||||||
|
|
||||||
|
audit_log::record(
|
||||||
|
&state.pool,
|
||||||
|
Some(user.id),
|
||||||
|
"network.delete",
|
||||||
|
Some("network"),
|
||||||
|
Some(&network.id.to_string()),
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"network_id": network.network_id,
|
||||||
|
"control_plane_id": control_plane.id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
async fn create_network(
|
async fn create_network(
|
||||||
State(state): State<crate::app_state::AppState>,
|
State(state): State<crate::app_state::AppState>,
|
||||||
AuthUser { user }: AuthUser,
|
AuthUser { user }: AuthUser,
|
||||||
|
|
@ -183,6 +224,8 @@ async fn create_network(
|
||||||
let response = client
|
let response = client
|
||||||
.create_network(CpCreateNetworkRequest {
|
.create_network(CpCreateNetworkRequest {
|
||||||
name: req.name,
|
name: req.name,
|
||||||
|
overlay_v4: req.overlay_v4.clone(),
|
||||||
|
overlay_v6: req.overlay_v6.clone(),
|
||||||
dns_domain: req.dns_domain.clone(),
|
dns_domain: req.dns_domain.clone(),
|
||||||
requires_approval: req.requires_approval,
|
requires_approval: req.requires_approval,
|
||||||
key_rotation_max_age_seconds: req.key_rotation_max_age_seconds,
|
key_rotation_max_age_seconds: req.key_rotation_max_age_seconds,
|
||||||
|
|
@ -249,6 +292,9 @@ async fn create_token(
|
||||||
ttl_seconds: req.ttl_seconds,
|
ttl_seconds: req.ttl_seconds,
|
||||||
uses: req.uses,
|
uses: req.uses,
|
||||||
tags: req.tags,
|
tags: req.tags,
|
||||||
|
owner_user_id: Some(user.id.to_string()),
|
||||||
|
owner_email: Some(user.email.clone()),
|
||||||
|
owner_is_admin: Some(true),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ pub struct DatabaseConfig {
|
||||||
pub url: String,
|
pub url: String,
|
||||||
#[serde(default = "default_max_connections")]
|
#[serde(default = "default_max_connections")]
|
||||||
pub max_connections: u32,
|
pub max_connections: u32,
|
||||||
|
#[serde(default = "default_disable_migration_locking")]
|
||||||
|
pub disable_migration_locking: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
|
@ -80,6 +82,10 @@ fn default_max_connections() -> u32 {
|
||||||
20
|
20
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_disable_migration_locking() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
fn default_session_ttl_minutes() -> i64 {
|
fn default_session_ttl_minutes() -> i64 {
|
||||||
720
|
720
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ impl ControlPlaneClient {
|
||||||
|
|
||||||
fn auth_header(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
fn auth_header(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||||
if let Some(token) = &self.admin_token {
|
if let Some(token) = &self.admin_token {
|
||||||
req.header("X-Lightscale-Admin-Token", token)
|
req.bearer_auth(token)
|
||||||
} else {
|
} else {
|
||||||
req
|
req
|
||||||
}
|
}
|
||||||
|
|
@ -199,11 +199,25 @@ impl ControlPlaneClient {
|
||||||
.map_err(|err| anyhow!("audit log failed: {}", err))?;
|
.map_err(|err| anyhow!("audit log failed: {}", err))?;
|
||||||
Ok(resp.json().await?)
|
Ok(resp.json().await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn delete_network(&self, network_id: &str) -> Result<()> {
|
||||||
|
let req = self
|
||||||
|
.client
|
||||||
|
.delete(self.url(&format!("/v1/networks/{}", network_id)));
|
||||||
|
self.auth_header(req)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()
|
||||||
|
.map_err(|err| anyhow!("delete network failed: {}", err))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct CreateNetworkRequest {
|
pub struct CreateNetworkRequest {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
pub overlay_v4: Option<String>,
|
||||||
|
pub overlay_v6: Option<String>,
|
||||||
pub dns_domain: Option<String>,
|
pub dns_domain: Option<String>,
|
||||||
pub requires_approval: Option<bool>,
|
pub requires_approval: Option<bool>,
|
||||||
pub key_rotation_max_age_seconds: Option<u64>,
|
pub key_rotation_max_age_seconds: Option<u64>,
|
||||||
|
|
@ -236,6 +250,12 @@ pub struct EnrollmentToken {
|
||||||
pub expires_at: i64,
|
pub expires_at: i64,
|
||||||
pub uses_left: u32,
|
pub uses_left: u32,
|
||||||
pub tags: Vec<String>,
|
pub tags: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub owner_user_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub owner_email: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub owner_is_admin: bool,
|
||||||
pub revoked_at: Option<i64>,
|
pub revoked_at: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -244,6 +264,12 @@ pub struct CreateTokenRequest {
|
||||||
pub ttl_seconds: u64,
|
pub ttl_seconds: u64,
|
||||||
pub uses: u32,
|
pub uses: u32,
|
||||||
pub tags: Vec<String>,
|
pub tags: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub owner_user_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub owner_email: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub owner_is_admin: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
|
@ -267,6 +293,12 @@ pub struct NodeInfo {
|
||||||
pub machine_public_key: String,
|
pub machine_public_key: String,
|
||||||
pub endpoints: Vec<String>,
|
pub endpoints: Vec<String>,
|
||||||
pub tags: Vec<String>,
|
pub tags: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub owner_user_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub owner_email: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub owner_is_admin: bool,
|
||||||
pub routes: Vec<Route>,
|
pub routes: Vec<Route>,
|
||||||
pub last_seen: i64,
|
pub last_seen: i64,
|
||||||
pub approved: bool,
|
pub approved: bool,
|
||||||
|
|
@ -364,3 +396,47 @@ pub struct AuditLogQuery {
|
||||||
pub node_id: Option<String>,
|
pub node_id: Option<String>,
|
||||||
pub limit: Option<usize>,
|
pub limit: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use reqwest::header::AUTHORIZATION;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_header_uses_bearer_token() {
|
||||||
|
let client = ControlPlaneClient::new(
|
||||||
|
"https://cp.example.com".to_string(),
|
||||||
|
Some("topsecret".to_string()),
|
||||||
|
);
|
||||||
|
let request = client
|
||||||
|
.auth_header(client.client.get(client.url("/healthz")))
|
||||||
|
.build()
|
||||||
|
.expect("request should build");
|
||||||
|
let auth = request
|
||||||
|
.headers()
|
||||||
|
.get(AUTHORIZATION)
|
||||||
|
.and_then(|value| value.to_str().ok());
|
||||||
|
assert_eq!(auth, Some("Bearer topsecret"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_network_request_serializes_overlay_cidrs() {
|
||||||
|
let request = CreateNetworkRequest {
|
||||||
|
name: "lab".to_string(),
|
||||||
|
overlay_v4: Some("100.120.0.0/24".to_string()),
|
||||||
|
overlay_v6: Some("fd42:120:0::/48".to_string()),
|
||||||
|
dns_domain: Some("lab.lightscale".to_string()),
|
||||||
|
requires_approval: Some(true),
|
||||||
|
key_rotation_max_age_seconds: Some(3600),
|
||||||
|
bootstrap_token_ttl_seconds: Some(600),
|
||||||
|
bootstrap_token_uses: Some(1),
|
||||||
|
bootstrap_token_tags: Some(vec!["dev".to_string()]),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_value(request).expect("request should serialize");
|
||||||
|
assert_eq!(json.get("overlay_v4").and_then(Value::as_str), Some("100.120.0.0/24"));
|
||||||
|
assert_eq!(
|
||||||
|
json.get("overlay_v6").and_then(Value::as_str),
|
||||||
|
Some("fd42:120:0::/48")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,3 +7,8 @@ pub async fn connect(config: &DatabaseConfig) -> Result<PgPool, sqlx::Error> {
|
||||||
.connect(&config.url)
|
.connect(&config.url)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn is_cockroach(pool: &PgPool) -> Result<bool, sqlx::Error> {
|
||||||
|
let version: String = sqlx::query_scalar("SELECT version()").fetch_one(pool).await?;
|
||||||
|
Ok(version.contains("CockroachDB"))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,30 @@ async fn main() -> anyhow::Result<()> {
|
||||||
|
|
||||||
let config = Config::load()?;
|
let config = Config::load()?;
|
||||||
let pool = db::connect(&config.database).await?;
|
let pool = db::connect(&config.database).await?;
|
||||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
|
||||||
|
let mut disable_migration_locking = config.database.disable_migration_locking;
|
||||||
|
if !disable_migration_locking {
|
||||||
|
match db::is_cockroach(&pool).await {
|
||||||
|
Ok(true) => {
|
||||||
|
tracing::info!("detected CockroachDB, disabling SQLx migration locking");
|
||||||
|
disable_migration_locking = true;
|
||||||
|
}
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(
|
||||||
|
error = ?err,
|
||||||
|
"failed to detect database engine, keeping migration locking enabled"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut migrator = sqlx::migrate!("./migrations");
|
||||||
|
if disable_migration_locking {
|
||||||
|
// CockroachDB does not implement pg_advisory_lock used by SQLx migration locking.
|
||||||
|
migrator.set_locking(false);
|
||||||
|
}
|
||||||
|
migrator.run(&pool).await?;
|
||||||
|
|
||||||
ensure_bootstrap_admin(&pool, &config).await?;
|
ensure_bootstrap_admin(&pool, &config).await?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,13 @@ use sqlx::PgPool;
|
||||||
|
|
||||||
pub const PERM_CONTROL_PLANES_READ: &str = "control_planes:read";
|
pub const PERM_CONTROL_PLANES_READ: &str = "control_planes:read";
|
||||||
pub const PERM_CONTROL_PLANES_WRITE: &str = "control_planes:write";
|
pub const PERM_CONTROL_PLANES_WRITE: &str = "control_planes:write";
|
||||||
|
pub const PERM_CONSOLE_ACCESS: &str = "console:access";
|
||||||
pub const PERM_NETWORKS_READ: &str = "networks:read";
|
pub const PERM_NETWORKS_READ: &str = "networks:read";
|
||||||
pub const PERM_NETWORKS_WRITE: &str = "networks:write";
|
pub const PERM_NETWORKS_WRITE: &str = "networks:write";
|
||||||
pub const PERM_NODES_READ: &str = "nodes:read";
|
pub const PERM_NODES_READ: &str = "nodes:read";
|
||||||
pub const PERM_NODES_WRITE: &str = "nodes:write";
|
pub const PERM_NODES_WRITE: &str = "nodes:write";
|
||||||
pub const PERM_TOKENS_WRITE: &str = "tokens:write";
|
pub const PERM_TOKENS_WRITE: &str = "tokens:write";
|
||||||
|
pub const PERM_JOIN_TOKENS_CREATE: &str = "join_tokens:create";
|
||||||
pub const PERM_ACL_READ: &str = "acl:read";
|
pub const PERM_ACL_READ: &str = "acl:read";
|
||||||
pub const PERM_ACL_WRITE: &str = "acl:write";
|
pub const PERM_ACL_WRITE: &str = "acl:write";
|
||||||
pub const PERM_KEY_POLICY_READ: &str = "key_policy:read";
|
pub const PERM_KEY_POLICY_READ: &str = "key_policy:read";
|
||||||
|
|
@ -47,3 +49,33 @@ pub async fn ensure_permission(
|
||||||
Err(ApiError::Forbidden)
|
Err(ApiError::Forbidden)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_permissions_global(pool: &PgPool, user: &User) -> Result<Vec<String>, ApiError> {
|
||||||
|
if user.super_admin {
|
||||||
|
let permissions = sqlx::query_scalar::<_, String>(
|
||||||
|
"SELECT DISTINCT permission FROM role_permissions ORDER BY permission",
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::Internal)?;
|
||||||
|
return Ok(permissions);
|
||||||
|
}
|
||||||
|
|
||||||
|
let permissions = sqlx::query_scalar::<_, String>(
|
||||||
|
"\
|
||||||
|
SELECT DISTINCT rp.permission\n\
|
||||||
|
FROM memberships m\n\
|
||||||
|
JOIN role_permissions rp ON rp.role_id = m.role_id\n\
|
||||||
|
WHERE m.user_id = $1\n\
|
||||||
|
AND m.scope_type = 'global'\n\
|
||||||
|
AND m.scope_id = 'global'\n\
|
||||||
|
ORDER BY rp.permission\n\
|
||||||
|
",
|
||||||
|
)
|
||||||
|
.bind(user.id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| ApiError::Internal)?;
|
||||||
|
|
||||||
|
Ok(permissions)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ static_dir = "frontend/dist"
|
||||||
[database]
|
[database]
|
||||||
url = "postgresql://root@localhost:26257/lightscale_admin?sslmode=disable"
|
url = "postgresql://root@localhost:26257/lightscale_admin?sslmode=disable"
|
||||||
max_connections = 20
|
max_connections = 20
|
||||||
|
# Optional override. CockroachDB is auto-detected and migration locking is disabled automatically.
|
||||||
|
disable_migration_locking = true
|
||||||
|
|
||||||
[auth]
|
[auth]
|
||||||
session_ttl_minutes = 720
|
session_ttl_minutes = 720
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,9 @@ import {
|
||||||
createNetwork,
|
createNetwork,
|
||||||
createToken,
|
createToken,
|
||||||
createUser,
|
createUser,
|
||||||
|
deleteControlPlane,
|
||||||
deleteMembership,
|
deleteMembership,
|
||||||
|
deleteNetwork,
|
||||||
getAcl,
|
getAcl,
|
||||||
getKeyPolicy,
|
getKeyPolicy,
|
||||||
getMe,
|
getMe,
|
||||||
|
|
@ -42,6 +44,8 @@ import {
|
||||||
type KeyPolicyResponse,
|
type KeyPolicyResponse,
|
||||||
type NetworkSummary,
|
type NetworkSummary,
|
||||||
type NodeInfo,
|
type NodeInfo,
|
||||||
|
type AuthSession,
|
||||||
|
type AuthUserSummary,
|
||||||
type ProviderSummary,
|
type ProviderSummary,
|
||||||
type RoleSummary,
|
type RoleSummary,
|
||||||
type UserDetail,
|
type UserDetail,
|
||||||
|
|
@ -82,12 +86,12 @@ const DEFAULT_SCOPE = { scope_type: 'global', scope_id: 'global' }
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [authReady, setAuthReady] = useState(false)
|
const [authReady, setAuthReady] = useState(false)
|
||||||
const [currentUser, setCurrentUser] = useState<UserSummary | null>(null)
|
const [session, setSession] = useState<AuthSession | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getMe()
|
getMe()
|
||||||
.then((user) => setCurrentUser(user))
|
.then((me) => setSession(me))
|
||||||
.catch(() => setCurrentUser(null))
|
.catch(() => setSession(null))
|
||||||
.finally(() => setAuthReady(true))
|
.finally(() => setAuthReady(true))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
@ -99,20 +103,46 @@ function App() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentUser) {
|
if (!session) {
|
||||||
return <LoginScreen onAuthed={setCurrentUser} />
|
return <LoginScreen onAuthed={setSession} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.console_access) {
|
||||||
|
return (
|
||||||
|
<div className="screen center">
|
||||||
|
<div className="card" style={{ maxWidth: 560 }}>
|
||||||
|
<h3>Console access is not granted</h3>
|
||||||
|
<p className="muted">
|
||||||
|
This account is authenticated, but it does not have the
|
||||||
|
<code>console:access</code> permission.
|
||||||
|
</p>
|
||||||
|
<div className="button-row">
|
||||||
|
<button
|
||||||
|
className="ghost"
|
||||||
|
onClick={async () => {
|
||||||
|
await logout()
|
||||||
|
setSession(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminShell
|
<AdminShell
|
||||||
user={currentUser}
|
user={session.user}
|
||||||
onLogout={() => setCurrentUser(null)}
|
permissions={session.permissions}
|
||||||
onUserUpdated={setCurrentUser}
|
onLogout={() => setSession(null)}
|
||||||
|
onSessionUpdated={setSession}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LoginScreen({ onAuthed }: { onAuthed: (user: UserSummary) => void }) {
|
function LoginScreen({ onAuthed }: { onAuthed: (session: AuthSession) => void }) {
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [providers, setProviders] = useState<ProviderSummary[]>([])
|
const [providers, setProviders] = useState<ProviderSummary[]>([])
|
||||||
|
|
@ -131,7 +161,7 @@ function LoginScreen({ onAuthed }: { onAuthed: (user: UserSummary) => void }) {
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const response = await login(email, password)
|
const response = await login(email, password)
|
||||||
onAuthed(response.user)
|
onAuthed(response)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message)
|
setError((err as Error).message)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -223,12 +253,14 @@ function LoginScreen({ onAuthed }: { onAuthed: (user: UserSummary) => void }) {
|
||||||
|
|
||||||
function AdminShell({
|
function AdminShell({
|
||||||
user,
|
user,
|
||||||
|
permissions,
|
||||||
onLogout,
|
onLogout,
|
||||||
onUserUpdated,
|
onSessionUpdated,
|
||||||
}: {
|
}: {
|
||||||
user: UserSummary
|
user: AuthUserSummary
|
||||||
|
permissions: string[]
|
||||||
onLogout: () => void
|
onLogout: () => void
|
||||||
onUserUpdated: (user: UserSummary) => void
|
onSessionUpdated: (session: AuthSession) => void
|
||||||
}) {
|
}) {
|
||||||
const [active, setActive] = useState<SectionId>('overview')
|
const [active, setActive] = useState<SectionId>('overview')
|
||||||
const [banner, setBanner] = useState<BannerMessage | null>(null)
|
const [banner, setBanner] = useState<BannerMessage | null>(null)
|
||||||
|
|
@ -259,12 +291,85 @@ function AdminShell({
|
||||||
|
|
||||||
const selectedNodeCount = nodesResponse?.nodes.length ?? 0
|
const selectedNodeCount = nodesResponse?.nodes.length ?? 0
|
||||||
|
|
||||||
|
const permissionSet = useMemo(() => new Set(permissions), [permissions])
|
||||||
|
const hasPermission = (permission: string) =>
|
||||||
|
user.super_admin || permissionSet.has(permission)
|
||||||
|
|
||||||
|
const canControlPlanesRead = hasPermission('control_planes:read')
|
||||||
|
const canControlPlanesWrite = hasPermission('control_planes:write')
|
||||||
|
const canNetworksRead = hasPermission('networks:read')
|
||||||
|
const canNetworksWrite = hasPermission('networks:write')
|
||||||
|
const canNodesRead = hasPermission('nodes:read')
|
||||||
|
const canNodesWrite = hasPermission('nodes:write')
|
||||||
|
const canTokensWrite = hasPermission('tokens:write')
|
||||||
|
const canAclRead = hasPermission('acl:read')
|
||||||
|
const canAclWrite = hasPermission('acl:write')
|
||||||
|
const canKeyPolicyRead = hasPermission('key_policy:read')
|
||||||
|
const canKeyPolicyWrite = hasPermission('key_policy:write')
|
||||||
|
const canUsersRead = hasPermission('users:read')
|
||||||
|
const canUsersWrite = hasPermission('users:write')
|
||||||
|
const canRolesRead = hasPermission('roles:read')
|
||||||
|
const canAuditRead = hasPermission('audit:read')
|
||||||
|
|
||||||
|
const visibleSections = useMemo(
|
||||||
|
() =>
|
||||||
|
sections.filter((section) => {
|
||||||
|
switch (section.id) {
|
||||||
|
case 'overview':
|
||||||
|
return true
|
||||||
|
case 'control-planes':
|
||||||
|
return canControlPlanesRead
|
||||||
|
case 'networks':
|
||||||
|
return canNetworksRead
|
||||||
|
case 'nodes':
|
||||||
|
return canNodesRead
|
||||||
|
case 'tokens':
|
||||||
|
return canTokensWrite
|
||||||
|
case 'acl':
|
||||||
|
return canAclRead
|
||||||
|
case 'key-policy':
|
||||||
|
return canKeyPolicyRead
|
||||||
|
case 'users':
|
||||||
|
return canUsersRead
|
||||||
|
case 'audit':
|
||||||
|
return canAuditRead
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
canAclRead,
|
||||||
|
canAuditRead,
|
||||||
|
canControlPlanesRead,
|
||||||
|
canKeyPolicyRead,
|
||||||
|
canNetworksRead,
|
||||||
|
canNodesRead,
|
||||||
|
canTokensWrite,
|
||||||
|
canUsersRead,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (canControlPlanesRead) {
|
||||||
refreshControlPlanes()
|
refreshControlPlanes()
|
||||||
|
}
|
||||||
|
if (canNetworksRead) {
|
||||||
refreshNetworks()
|
refreshNetworks()
|
||||||
|
}
|
||||||
|
if (canRolesRead) {
|
||||||
refreshRoles()
|
refreshRoles()
|
||||||
|
}
|
||||||
|
if (canUsersRead) {
|
||||||
refreshUsers()
|
refreshUsers()
|
||||||
}, [])
|
}
|
||||||
|
}, [canControlPlanesRead, canNetworksRead, canRolesRead, canUsersRead])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visibleSections.some((section) => section.id === active)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (visibleSections.length > 0) {
|
||||||
|
setActive(visibleSections[0].id)
|
||||||
|
}
|
||||||
|
}, [active, visibleSections])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (controlPlanes.length && !selectedControlPlaneId) {
|
if (controlPlanes.length && !selectedControlPlaneId) {
|
||||||
|
|
@ -280,23 +385,35 @@ function AdminShell({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedNetworkId) return
|
if (!selectedNetworkId) return
|
||||||
|
if (canNodesRead) {
|
||||||
listNodes(selectedNetworkId)
|
listNodes(selectedNetworkId)
|
||||||
.then(setNodesResponse)
|
.then(setNodesResponse)
|
||||||
.catch((err) => handleError(err, 'error'))
|
.catch((err) => handleError(err, 'error'))
|
||||||
|
} else {
|
||||||
|
setNodesResponse(null)
|
||||||
|
}
|
||||||
|
if (canAclRead) {
|
||||||
getAcl(selectedNetworkId)
|
getAcl(selectedNetworkId)
|
||||||
.then((policy) => setAclText(JSON.stringify(policy, null, 2)))
|
.then((policy) => setAclText(JSON.stringify(policy, null, 2)))
|
||||||
.catch(() => setAclText(''))
|
.catch(() => setAclText(''))
|
||||||
|
} else {
|
||||||
|
setAclText('')
|
||||||
|
}
|
||||||
|
if (canKeyPolicyRead) {
|
||||||
getKeyPolicy(selectedNetworkId)
|
getKeyPolicy(selectedNetworkId)
|
||||||
.then(setKeyPolicy)
|
.then(setKeyPolicy)
|
||||||
.catch(() => setKeyPolicy(null))
|
.catch(() => setKeyPolicy(null))
|
||||||
}, [selectedNetworkId])
|
} else {
|
||||||
|
setKeyPolicy(null)
|
||||||
|
}
|
||||||
|
}, [selectedNetworkId, canAclRead, canKeyPolicyRead, canNodesRead])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (active !== 'audit') return
|
if (active !== 'audit' || !canAuditRead) return
|
||||||
listAdminAudit({ limit: 200 })
|
listAdminAudit({ limit: 200 })
|
||||||
.then(setAdminAudit)
|
.then(setAdminAudit)
|
||||||
.catch((err) => handleError(err, 'error'))
|
.catch((err) => handleError(err, 'error'))
|
||||||
}, [active])
|
}, [active, canAuditRead])
|
||||||
|
|
||||||
const refreshControlPlanes = async () => {
|
const refreshControlPlanes = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -359,7 +476,11 @@ function AdminShell({
|
||||||
const handleUpdateCurrentUser = async () => {
|
const handleUpdateCurrentUser = async () => {
|
||||||
try {
|
try {
|
||||||
const refreshed = await getMe()
|
const refreshed = await getMe()
|
||||||
onUserUpdated(refreshed)
|
if (!refreshed.console_access) {
|
||||||
|
onLogout()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onSessionUpdated(refreshed)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(err, 'warning')
|
handleError(err, 'warning')
|
||||||
}
|
}
|
||||||
|
|
@ -412,7 +533,7 @@ function AdminShell({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav className="nav">
|
<nav className="nav">
|
||||||
{sections.map((section) => (
|
{visibleSections.map((section) => (
|
||||||
<button
|
<button
|
||||||
key={section.id}
|
key={section.id}
|
||||||
className={active === section.id ? 'active' : ''}
|
className={active === section.id ? 'active' : ''}
|
||||||
|
|
@ -437,7 +558,7 @@ function AdminShell({
|
||||||
<main className="main">
|
<main className="main">
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div>
|
<div>
|
||||||
<h1>{sections.find((section) => section.id === active)?.label}</h1>
|
<h1>{visibleSections.find((section) => section.id === active)?.label || 'Overview'}</h1>
|
||||||
<p>
|
<p>
|
||||||
{selectedNetwork
|
{selectedNetwork
|
||||||
? `${selectedNetwork.name} · ${selectedNetwork.control_plane_name}`
|
? `${selectedNetwork.name} · ${selectedNetwork.control_plane_name}`
|
||||||
|
|
@ -501,12 +622,13 @@ function AdminShell({
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>Active signals</h3>
|
<h3>Active signals</h3>
|
||||||
<p>Audit log for admin actions and control plane events.</p>
|
<p>Audit log for admin actions and control plane events.</p>
|
||||||
<button
|
{canAuditRead ? (
|
||||||
className="ghost"
|
<button className="ghost" onClick={() => setActive('audit')}>
|
||||||
onClick={() => setActive('audit')}
|
|
||||||
>
|
|
||||||
View audit timeline
|
View audit timeline
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<p className="muted">No audit permission.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>Role coverage</h3>
|
<h3>Role coverage</h3>
|
||||||
|
|
@ -514,23 +636,32 @@ function AdminShell({
|
||||||
{roles.length} roles configured. {users.length} users in
|
{roles.length} roles configured. {users.length} users in
|
||||||
directory.
|
directory.
|
||||||
</p>
|
</p>
|
||||||
|
{canUsersRead ? (
|
||||||
<button className="ghost" onClick={() => setActive('users')}>
|
<button className="ghost" onClick={() => setActive('users')}>
|
||||||
Manage access
|
Manage access
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<p className="muted">No user-directory permission.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>Network posture</h3>
|
<h3>Network posture</h3>
|
||||||
<p>Keep ACLs tight and key rotation moving.</p>
|
<p>Keep ACLs tight and key rotation moving.</p>
|
||||||
|
{canAclRead ? (
|
||||||
<button className="ghost" onClick={() => setActive('acl')}>
|
<button className="ghost" onClick={() => setActive('acl')}>
|
||||||
Edit ACL
|
Edit ACL
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<p className="muted">No ACL permission.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{active === 'control-planes' && (
|
{active === 'control-planes' && canControlPlanesRead && (
|
||||||
<ControlPlaneSection
|
<ControlPlaneSection
|
||||||
controlPlanes={controlPlanes}
|
controlPlanes={controlPlanes}
|
||||||
|
canWrite={canControlPlanesWrite}
|
||||||
onCreate={async (payload) => {
|
onCreate={async (payload) => {
|
||||||
const created = await createControlPlane(payload)
|
const created = await createControlPlane(payload)
|
||||||
setControlPlanes((prev) => [...prev, created])
|
setControlPlanes((prev) => [...prev, created])
|
||||||
|
|
@ -542,31 +673,41 @@ function AdminShell({
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
onVerify={async (id) => verifyControlPlane(id)}
|
onVerify={async (id) => verifyControlPlane(id)}
|
||||||
|
onDelete={async (id) => {
|
||||||
|
await deleteControlPlane(id)
|
||||||
|
setControlPlanes((prev) => prev.filter((plane) => plane.id !== id))
|
||||||
|
}}
|
||||||
onBanner={setBanner}
|
onBanner={setBanner}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{active === 'networks' && (
|
{active === 'networks' && canNetworksRead && (
|
||||||
<NetworksSection
|
<NetworksSection
|
||||||
controlPlanes={controlPlanes}
|
controlPlanes={controlPlanes}
|
||||||
networks={networks}
|
networks={networks}
|
||||||
|
canWrite={canNetworksWrite}
|
||||||
onCreate={async (payload) => {
|
onCreate={async (payload) => {
|
||||||
const result = await createNetwork(payload)
|
const result = await createNetwork(payload)
|
||||||
setNetworks((prev) => [...prev, result.network])
|
setNetworks((prev) => [...prev, result.network])
|
||||||
setBootstrapToken(result.bootstrap_token || null)
|
setBootstrapToken(result.bootstrap_token || null)
|
||||||
setSelectedNetworkId(result.network.id)
|
setSelectedNetworkId(result.network.id)
|
||||||
}}
|
}}
|
||||||
|
onDelete={async (id) => {
|
||||||
|
await deleteNetwork(id)
|
||||||
|
setNetworks((prev) => prev.filter((network) => network.id !== id))
|
||||||
|
}}
|
||||||
onSelect={(id) => setSelectedNetworkId(id)}
|
onSelect={(id) => setSelectedNetworkId(id)}
|
||||||
selectedNetworkId={selectedNetworkId}
|
selectedNetworkId={selectedNetworkId}
|
||||||
bootstrapToken={bootstrapToken}
|
bootstrapToken={bootstrapToken}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{active === 'nodes' && (
|
{active === 'nodes' && canNodesRead && (
|
||||||
<NodesSection
|
<NodesSection
|
||||||
network={selectedNetwork}
|
network={selectedNetwork}
|
||||||
nodes={nodesResponse?.nodes || []}
|
nodes={nodesResponse?.nodes || []}
|
||||||
keyHistory={keyHistory}
|
keyHistory={keyHistory}
|
||||||
|
canWrite={canNodesWrite}
|
||||||
onApprove={(nodeId) =>
|
onApprove={(nodeId) =>
|
||||||
handleNodeAction(() => approveNode(selectedNetworkId, nodeId))
|
handleNodeAction(() => approveNode(selectedNetworkId, nodeId))
|
||||||
}
|
}
|
||||||
|
|
@ -588,7 +729,7 @@ function AdminShell({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{active === 'tokens' && (
|
{active === 'tokens' && canTokensWrite && (
|
||||||
<TokensSection
|
<TokensSection
|
||||||
network={selectedNetwork}
|
network={selectedNetwork}
|
||||||
tokenResult={tokenResult}
|
tokenResult={tokenResult}
|
||||||
|
|
@ -607,28 +748,31 @@ function AdminShell({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{active === 'acl' && (
|
{active === 'acl' && canAclRead && (
|
||||||
<AclSection
|
<AclSection
|
||||||
network={selectedNetwork}
|
network={selectedNetwork}
|
||||||
aclText={aclText}
|
aclText={aclText}
|
||||||
|
canWrite={canAclWrite}
|
||||||
onChange={setAclText}
|
onChange={setAclText}
|
||||||
onSave={handleAclSave}
|
onSave={handleAclSave}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{active === 'key-policy' && (
|
{active === 'key-policy' && canKeyPolicyRead && (
|
||||||
<KeyPolicySection
|
<KeyPolicySection
|
||||||
network={selectedNetwork}
|
network={selectedNetwork}
|
||||||
policy={keyPolicy}
|
policy={keyPolicy}
|
||||||
|
canWrite={canKeyPolicyWrite}
|
||||||
onSave={handleKeyPolicySave}
|
onSave={handleKeyPolicySave}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{active === 'users' && (
|
{active === 'users' && canUsersRead && (
|
||||||
<UsersSection
|
<UsersSection
|
||||||
users={users}
|
users={users}
|
||||||
roles={roles}
|
roles={roles}
|
||||||
selectedUser={selectedUser}
|
selectedUser={selectedUser}
|
||||||
|
canWrite={canUsersWrite}
|
||||||
onSelect={handleSelectUser}
|
onSelect={handleSelectUser}
|
||||||
onCreate={async (payload) => {
|
onCreate={async (payload) => {
|
||||||
const created = await createUser(payload)
|
const created = await createUser(payload)
|
||||||
|
|
@ -641,7 +785,10 @@ function AdminShell({
|
||||||
prev.map((record) => (record.id === updated.id ? updated : record)),
|
prev.map((record) => (record.id === updated.id ? updated : record)),
|
||||||
)
|
)
|
||||||
if (selectedUser && selectedUser.user.id === updated.id) {
|
if (selectedUser && selectedUser.user.id === updated.id) {
|
||||||
setSelectedUser({ ...selectedUser, user: updated })
|
setSelectedUser({
|
||||||
|
...selectedUser,
|
||||||
|
user: updated,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onSetPassword={async (userId, password) => {
|
onSetPassword={async (userId, password) => {
|
||||||
|
|
@ -670,7 +817,7 @@ function AdminShell({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{active === 'audit' && (
|
{active === 'audit' && canAuditRead && (
|
||||||
<AuditSection
|
<AuditSection
|
||||||
controlPlanes={controlPlanes}
|
controlPlanes={controlPlanes}
|
||||||
adminAudit={adminAudit}
|
adminAudit={adminAudit}
|
||||||
|
|
@ -693,12 +840,15 @@ function AdminShell({
|
||||||
|
|
||||||
function ControlPlaneSection({
|
function ControlPlaneSection({
|
||||||
controlPlanes,
|
controlPlanes,
|
||||||
|
canWrite,
|
||||||
onCreate,
|
onCreate,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onVerify,
|
onVerify,
|
||||||
|
onDelete,
|
||||||
onBanner,
|
onBanner,
|
||||||
}: {
|
}: {
|
||||||
controlPlanes: ControlPlaneSummary[]
|
controlPlanes: ControlPlaneSummary[]
|
||||||
|
canWrite: boolean
|
||||||
onCreate: (payload: {
|
onCreate: (payload: {
|
||||||
name: string
|
name: string
|
||||||
base_url: string
|
base_url: string
|
||||||
|
|
@ -716,6 +866,7 @@ function ControlPlaneSection({
|
||||||
},
|
},
|
||||||
) => Promise<void>
|
) => Promise<void>
|
||||||
onVerify: (id: string) => Promise<{ ok: boolean; status?: number; body?: string }>
|
onVerify: (id: string) => Promise<{ ok: boolean; status?: number; body?: string }>
|
||||||
|
onDelete: (id: string) => Promise<void>
|
||||||
onBanner: (message: BannerMessage) => void
|
onBanner: (message: BannerMessage) => void
|
||||||
}) {
|
}) {
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
|
|
@ -741,6 +892,7 @@ function ControlPlaneSection({
|
||||||
|
|
||||||
const handleSubmit = async (event: FormEvent) => {
|
const handleSubmit = async (event: FormEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
if (!canWrite) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
if (form.id) {
|
if (form.id) {
|
||||||
|
|
@ -820,7 +972,7 @@ function ControlPlaneSection({
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className="button-row">
|
<div className="button-row">
|
||||||
<button type="submit" disabled={loading}>
|
<button type="submit" disabled={loading || !canWrite}>
|
||||||
{loading ? 'Saving...' : form.id ? 'Update plane' : 'Create plane'}
|
{loading ? 'Saving...' : form.id ? 'Update plane' : 'Create plane'}
|
||||||
</button>
|
</button>
|
||||||
{form.id && (
|
{form.id && (
|
||||||
|
|
@ -861,6 +1013,8 @@ function ControlPlaneSection({
|
||||||
>
|
>
|
||||||
Verify
|
Verify
|
||||||
</button>
|
</button>
|
||||||
|
{canWrite && (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
className="ghost"
|
className="ghost"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
|
|
@ -876,6 +1030,18 @@ function ControlPlaneSection({
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost danger"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Delete control plane "${plane.name}"?`)) {
|
||||||
|
onDelete(plane.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -891,16 +1057,21 @@ function ControlPlaneSection({
|
||||||
function NetworksSection({
|
function NetworksSection({
|
||||||
controlPlanes,
|
controlPlanes,
|
||||||
networks,
|
networks,
|
||||||
|
canWrite,
|
||||||
onCreate,
|
onCreate,
|
||||||
|
onDelete,
|
||||||
onSelect,
|
onSelect,
|
||||||
selectedNetworkId,
|
selectedNetworkId,
|
||||||
bootstrapToken,
|
bootstrapToken,
|
||||||
}: {
|
}: {
|
||||||
controlPlanes: ControlPlaneSummary[]
|
controlPlanes: ControlPlaneSummary[]
|
||||||
networks: NetworkSummary[]
|
networks: NetworkSummary[]
|
||||||
|
canWrite: boolean
|
||||||
onCreate: (payload: {
|
onCreate: (payload: {
|
||||||
control_plane_id: string
|
control_plane_id: string
|
||||||
name: string
|
name: string
|
||||||
|
overlay_v4?: string
|
||||||
|
overlay_v6?: string
|
||||||
dns_domain?: string
|
dns_domain?: string
|
||||||
requires_approval?: boolean
|
requires_approval?: boolean
|
||||||
key_rotation_max_age_seconds?: number
|
key_rotation_max_age_seconds?: number
|
||||||
|
|
@ -908,6 +1079,7 @@ function NetworksSection({
|
||||||
bootstrap_token_uses?: number
|
bootstrap_token_uses?: number
|
||||||
bootstrap_token_tags?: string[]
|
bootstrap_token_tags?: string[]
|
||||||
}) => Promise<void>
|
}) => Promise<void>
|
||||||
|
onDelete: (id: string) => Promise<void>
|
||||||
onSelect: (id: string) => void
|
onSelect: (id: string) => void
|
||||||
selectedNetworkId: string
|
selectedNetworkId: string
|
||||||
bootstrapToken: EnrollmentToken | null
|
bootstrapToken: EnrollmentToken | null
|
||||||
|
|
@ -915,6 +1087,8 @@ function NetworksSection({
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
control_plane_id: '',
|
control_plane_id: '',
|
||||||
name: '',
|
name: '',
|
||||||
|
overlay_v4: '',
|
||||||
|
overlay_v6: '',
|
||||||
dns_domain: '',
|
dns_domain: '',
|
||||||
requires_approval: false,
|
requires_approval: false,
|
||||||
key_rotation_max_age_seconds: '',
|
key_rotation_max_age_seconds: '',
|
||||||
|
|
@ -925,10 +1099,13 @@ function NetworksSection({
|
||||||
|
|
||||||
const handleSubmit = async (event: FormEvent) => {
|
const handleSubmit = async (event: FormEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
if (!canWrite) return
|
||||||
if (!form.control_plane_id) return
|
if (!form.control_plane_id) return
|
||||||
await onCreate({
|
await onCreate({
|
||||||
control_plane_id: form.control_plane_id,
|
control_plane_id: form.control_plane_id,
|
||||||
name: form.name,
|
name: form.name,
|
||||||
|
overlay_v4: form.overlay_v4 || undefined,
|
||||||
|
overlay_v6: form.overlay_v6 || undefined,
|
||||||
dns_domain: form.dns_domain || undefined,
|
dns_domain: form.dns_domain || undefined,
|
||||||
requires_approval: form.requires_approval,
|
requires_approval: form.requires_approval,
|
||||||
key_rotation_max_age_seconds: form.key_rotation_max_age_seconds
|
key_rotation_max_age_seconds: form.key_rotation_max_age_seconds
|
||||||
|
|
@ -947,6 +1124,8 @@ function NetworksSection({
|
||||||
setForm({
|
setForm({
|
||||||
control_plane_id: form.control_plane_id,
|
control_plane_id: form.control_plane_id,
|
||||||
name: '',
|
name: '',
|
||||||
|
overlay_v4: '',
|
||||||
|
overlay_v6: '',
|
||||||
dns_domain: '',
|
dns_domain: '',
|
||||||
requires_approval: false,
|
requires_approval: false,
|
||||||
key_rotation_max_age_seconds: '',
|
key_rotation_max_age_seconds: '',
|
||||||
|
|
@ -986,6 +1165,28 @@ function NetworksSection({
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<div className="inline">
|
||||||
|
<label>
|
||||||
|
<span>Overlay IPv4 CIDR</span>
|
||||||
|
<input
|
||||||
|
value={form.overlay_v4}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm({ ...form, overlay_v4: event.target.value })
|
||||||
|
}
|
||||||
|
placeholder="100.120.0.0/24"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Overlay IPv6 CIDR</span>
|
||||||
|
<input
|
||||||
|
value={form.overlay_v6}
|
||||||
|
onChange={(event) =>
|
||||||
|
setForm({ ...form, overlay_v6: event.target.value })
|
||||||
|
}
|
||||||
|
placeholder="fd42:120:0::/48"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<label>
|
<label>
|
||||||
<span>DNS domain</span>
|
<span>DNS domain</span>
|
||||||
<input
|
<input
|
||||||
|
|
@ -1056,7 +1257,7 @@ function NetworksSection({
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit">Create network</button>
|
<button type="submit" disabled={!canWrite}>Create network</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{bootstrapToken && (
|
{bootstrapToken && (
|
||||||
|
|
@ -1074,15 +1275,17 @@ function NetworksSection({
|
||||||
<h3>Known networks</h3>
|
<h3>Known networks</h3>
|
||||||
<div className="list">
|
<div className="list">
|
||||||
{networks.map((network, index) => (
|
{networks.map((network, index) => (
|
||||||
<button
|
<div
|
||||||
key={network.id}
|
key={network.id}
|
||||||
className={`list-row selectable ${
|
className={`list-row ${
|
||||||
network.id === selectedNetworkId ? 'active' : ''
|
network.id === selectedNetworkId ? 'active' : ''
|
||||||
}`}
|
}`}
|
||||||
style={{ animationDelay: `${index * 40}ms` }}
|
style={{ animationDelay: `${index * 40}ms` }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="selectable-area"
|
||||||
onClick={() => onSelect(network.id)}
|
onClick={() => onSelect(network.id)}
|
||||||
>
|
>
|
||||||
<div>
|
|
||||||
<strong>{network.name}</strong>
|
<strong>{network.name}</strong>
|
||||||
<p>{network.network_id}</p>
|
<p>{network.network_id}</p>
|
||||||
<span className="tag">{network.control_plane_name}</span>
|
<span className="tag">{network.control_plane_name}</span>
|
||||||
|
|
@ -1091,7 +1294,21 @@ function NetworksSection({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mono">{network.overlay_v4 || '-'}</div>
|
<div className="mono">{network.overlay_v4 || '-'}</div>
|
||||||
|
<div className="button-row">
|
||||||
|
{canWrite && (
|
||||||
|
<button
|
||||||
|
className="ghost danger"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Delete network "${network.name}"?`)) {
|
||||||
|
onDelete(network.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
{networks.length === 0 && <p className="muted">No networks yet.</p>}
|
{networks.length === 0 && <p className="muted">No networks yet.</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1104,6 +1321,7 @@ function NodesSection({
|
||||||
network,
|
network,
|
||||||
nodes,
|
nodes,
|
||||||
keyHistory,
|
keyHistory,
|
||||||
|
canWrite,
|
||||||
onApprove,
|
onApprove,
|
||||||
onRevoke,
|
onRevoke,
|
||||||
onRotate,
|
onRotate,
|
||||||
|
|
@ -1112,6 +1330,7 @@ function NodesSection({
|
||||||
network: NetworkSummary | null
|
network: NetworkSummary | null
|
||||||
nodes: NodeInfo[]
|
nodes: NodeInfo[]
|
||||||
keyHistory: KeyHistoryResponse | null
|
keyHistory: KeyHistoryResponse | null
|
||||||
|
canWrite: boolean
|
||||||
onApprove: (nodeId: string) => void
|
onApprove: (nodeId: string) => void
|
||||||
onRevoke: (nodeId: string) => void
|
onRevoke: (nodeId: string) => void
|
||||||
onRotate: (nodeId: string) => void
|
onRotate: (nodeId: string) => void
|
||||||
|
|
@ -1137,25 +1356,29 @@ function NodesSection({
|
||||||
) : (
|
) : (
|
||||||
<span className="tag warning">pending</span>
|
<span className="tag warning">pending</span>
|
||||||
)}
|
)}
|
||||||
|
{node.owner_is_admin && <span className="tag">admin-issued</span>}
|
||||||
{node.revoked && <span className="tag danger">revoked</span>}
|
{node.revoked && <span className="tag danger">revoked</span>}
|
||||||
{node.key_rotation_required && (
|
{node.key_rotation_required && (
|
||||||
<span className="tag warning">rotate keys</span>
|
<span className="tag warning">rotate keys</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{node.owner_email && <small>Owner: {node.owner_email}</small>}
|
||||||
<small>Last seen: {formatEpoch(node.last_seen)}</small>
|
<small>Last seen: {formatEpoch(node.last_seen)}</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row">
|
<div className="button-row">
|
||||||
{!node.approved && !node.revoked && (
|
{canWrite && !node.approved && !node.revoked && (
|
||||||
<button onClick={() => onApprove(node.id)}>Approve</button>
|
<button onClick={() => onApprove(node.id)}>Approve</button>
|
||||||
)}
|
)}
|
||||||
{!node.revoked && (
|
{canWrite && !node.revoked && (
|
||||||
<button className="ghost" onClick={() => onRevoke(node.id)}>
|
<button className="ghost" onClick={() => onRevoke(node.id)}>
|
||||||
Revoke
|
Revoke
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{canWrite && (
|
||||||
<button className="ghost" onClick={() => onRotate(node.id)}>
|
<button className="ghost" onClick={() => onRotate(node.id)}>
|
||||||
Rotate keys
|
Rotate keys
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<button className="ghost" onClick={() => onViewKeys(node.id)}>
|
<button className="ghost" onClick={() => onViewKeys(node.id)}>
|
||||||
View keys
|
View keys
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -1267,6 +1490,14 @@ function TokensSection({
|
||||||
<span>Uses left: {tokenResult.token.uses_left}</span>
|
<span>Uses left: {tokenResult.token.uses_left}</span>
|
||||||
<span>Expires: {formatEpoch(tokenResult.token.expires_at)}</span>
|
<span>Expires: {formatEpoch(tokenResult.token.expires_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{(tokenResult.token.owner_email || tokenResult.token.owner_is_admin) && (
|
||||||
|
<div className="meta">
|
||||||
|
{tokenResult.token.owner_email && (
|
||||||
|
<span>Owner: {tokenResult.token.owner_email}</span>
|
||||||
|
)}
|
||||||
|
{tokenResult.token.owner_is_admin && <span>Privilege: admin</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1305,11 +1536,13 @@ function TokensSection({
|
||||||
function AclSection({
|
function AclSection({
|
||||||
network,
|
network,
|
||||||
aclText,
|
aclText,
|
||||||
|
canWrite,
|
||||||
onChange,
|
onChange,
|
||||||
onSave,
|
onSave,
|
||||||
}: {
|
}: {
|
||||||
network: NetworkSummary | null
|
network: NetworkSummary | null
|
||||||
aclText: string
|
aclText: string
|
||||||
|
canWrite: boolean
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -1320,9 +1553,15 @@ function AclSection({
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>ACL policy for {network.name}</h3>
|
<h3>ACL policy for {network.name}</h3>
|
||||||
<textarea value={aclText} onChange={(event) => onChange(event.target.value)} />
|
<textarea
|
||||||
|
value={aclText}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
disabled={!canWrite}
|
||||||
|
/>
|
||||||
<div className="button-row">
|
<div className="button-row">
|
||||||
<button onClick={onSave}>Save policy</button>
|
<button onClick={onSave} disabled={!canWrite}>
|
||||||
|
Save policy
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -1331,10 +1570,12 @@ function AclSection({
|
||||||
function KeyPolicySection({
|
function KeyPolicySection({
|
||||||
network,
|
network,
|
||||||
policy,
|
policy,
|
||||||
|
canWrite,
|
||||||
onSave,
|
onSave,
|
||||||
}: {
|
}: {
|
||||||
network: NetworkSummary | null
|
network: NetworkSummary | null
|
||||||
policy: KeyPolicyResponse | null
|
policy: KeyPolicyResponse | null
|
||||||
|
canWrite: boolean
|
||||||
onSave: (maxAge: number | null) => void
|
onSave: (maxAge: number | null) => void
|
||||||
}) {
|
}) {
|
||||||
const [maxAge, setMaxAge] = useState('')
|
const [maxAge, setMaxAge] = useState('')
|
||||||
|
|
@ -1360,10 +1601,14 @@ function KeyPolicySection({
|
||||||
type="number"
|
type="number"
|
||||||
value={maxAge}
|
value={maxAge}
|
||||||
onChange={(event) => setMaxAge(event.target.value)}
|
onChange={(event) => setMaxAge(event.target.value)}
|
||||||
|
disabled={!canWrite}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className="button-row">
|
<div className="button-row">
|
||||||
<button onClick={() => onSave(maxAge ? Number(maxAge) : null)}>
|
<button
|
||||||
|
onClick={() => onSave(maxAge ? Number(maxAge) : null)}
|
||||||
|
disabled={!canWrite}
|
||||||
|
>
|
||||||
Save policy
|
Save policy
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1375,6 +1620,7 @@ function UsersSection({
|
||||||
users,
|
users,
|
||||||
roles,
|
roles,
|
||||||
selectedUser,
|
selectedUser,
|
||||||
|
canWrite,
|
||||||
onSelect,
|
onSelect,
|
||||||
onCreate,
|
onCreate,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
|
|
@ -1385,6 +1631,7 @@ function UsersSection({
|
||||||
users: UserSummary[]
|
users: UserSummary[]
|
||||||
roles: RoleSummary[]
|
roles: RoleSummary[]
|
||||||
selectedUser: UserDetail | null
|
selectedUser: UserDetail | null
|
||||||
|
canWrite: boolean
|
||||||
onSelect: (id: string) => void
|
onSelect: (id: string) => void
|
||||||
onCreate: (payload: {
|
onCreate: (payload: {
|
||||||
email: string
|
email: string
|
||||||
|
|
@ -1446,6 +1693,7 @@ function UsersSection({
|
||||||
<form
|
<form
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
if (!canWrite) return
|
||||||
onCreate({
|
onCreate({
|
||||||
email: createForm.email,
|
email: createForm.email,
|
||||||
display_name: createForm.display_name || undefined,
|
display_name: createForm.display_name || undefined,
|
||||||
|
|
@ -1519,7 +1767,8 @@ function UsersSection({
|
||||||
/>
|
/>
|
||||||
<span>Super admin</span>
|
<span>Super admin</span>
|
||||||
</label>
|
</label>
|
||||||
<button type="submit">Create user</button>
|
<button type="submit" disabled={!canWrite}>Create user</button>
|
||||||
|
{!canWrite && <p className="muted">Read-only.</p>}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
|
|
@ -1583,6 +1832,7 @@ function UsersSection({
|
||||||
<span>Super admin</span>
|
<span>Super admin</span>
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
|
disabled={!canWrite}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onUpdate(selectedUser.user.id, {
|
onUpdate(selectedUser.user.id, {
|
||||||
display_name: updateForm.display_name,
|
display_name: updateForm.display_name,
|
||||||
|
|
@ -1604,6 +1854,7 @@ function UsersSection({
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
className="ghost"
|
className="ghost"
|
||||||
|
disabled={!canWrite}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!password) return
|
if (!password) return
|
||||||
onSetPassword(selectedUser.user.id, password)
|
onSetPassword(selectedUser.user.id, password)
|
||||||
|
|
@ -1632,6 +1883,7 @@ function UsersSection({
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="ghost"
|
className="ghost"
|
||||||
|
disabled={!canWrite}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onDeleteMembership(selectedUser.user.id, membership.id)
|
onDeleteMembership(selectedUser.user.id, membership.id)
|
||||||
}
|
}
|
||||||
|
|
@ -1647,6 +1899,7 @@ function UsersSection({
|
||||||
<form
|
<form
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
if (!canWrite) return
|
||||||
if (!membershipForm.role_id) return
|
if (!membershipForm.role_id) return
|
||||||
onAddMembership(selectedUser.user.id, membershipForm)
|
onAddMembership(selectedUser.user.id, membershipForm)
|
||||||
}}
|
}}
|
||||||
|
|
@ -1697,7 +1950,7 @@ function UsersSection({
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit">Add membership</button>
|
<button type="submit" disabled={!canWrite}>Add membership</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,19 @@ export type UserSummary = {
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AuthUserSummary = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
display_name?: string | null;
|
||||||
|
super_admin: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuthSession = {
|
||||||
|
user: AuthUserSummary;
|
||||||
|
permissions: string[];
|
||||||
|
console_access: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type MembershipSummary = {
|
export type MembershipSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
role_id: string;
|
role_id: string;
|
||||||
|
|
@ -97,6 +110,9 @@ export type NodeInfo = {
|
||||||
machine_public_key: string;
|
machine_public_key: string;
|
||||||
endpoints: string[];
|
endpoints: string[];
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
owner_user_id?: string | null;
|
||||||
|
owner_email?: string | null;
|
||||||
|
owner_is_admin?: boolean;
|
||||||
routes: RouteInfo[];
|
routes: RouteInfo[];
|
||||||
last_seen: number;
|
last_seen: number;
|
||||||
approved: boolean;
|
approved: boolean;
|
||||||
|
|
@ -113,6 +129,9 @@ export type EnrollmentToken = {
|
||||||
expires_at: number;
|
expires_at: number;
|
||||||
uses_left: number;
|
uses_left: number;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
owner_user_id?: string | null;
|
||||||
|
owner_email?: string | null;
|
||||||
|
owner_is_admin?: boolean;
|
||||||
revoked_at?: number | null;
|
revoked_at?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -188,11 +207,11 @@ export type ProviderSummary = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getMe() {
|
export async function getMe() {
|
||||||
return request<UserSummary>('/auth/me');
|
return request<AuthSession>('/auth/me');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function login(email: string, password: string) {
|
export async function login(email: string, password: string) {
|
||||||
return request<{ user: UserSummary }>('/auth/login', {
|
return request<AuthSession>('/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
});
|
});
|
||||||
|
|
@ -249,6 +268,8 @@ export async function listNetworks() {
|
||||||
export async function createNetwork(payload: {
|
export async function createNetwork(payload: {
|
||||||
control_plane_id: string;
|
control_plane_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
overlay_v4?: string;
|
||||||
|
overlay_v6?: string;
|
||||||
dns_domain?: string;
|
dns_domain?: string;
|
||||||
requires_approval?: boolean;
|
requires_approval?: boolean;
|
||||||
key_rotation_max_age_seconds?: number;
|
key_rotation_max_age_seconds?: number;
|
||||||
|
|
@ -419,3 +440,23 @@ export async function listControlPlaneAudit(
|
||||||
`/audit/control-planes/${controlPlaneId}${query ? `?${query}` : ''}`,
|
`/audit/control-planes/${controlPlaneId}${query ? `?${query}` : ''}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteNetwork(id: string): Promise<void> {
|
||||||
|
const res = await fetch(`${API_BASE}/networks/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to delete network: ${res.statusText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteControlPlane(id: string): Promise<void> {
|
||||||
|
const res = await fetch(`${API_BASE}/control-planes/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to delete control plane: ${res.statusText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue