Add user profiles with node adoption via /v1/adoptions endpoint

Move adopt/release from profile routes to dedicated /v1/adoptions endpoint.
Node API now returns adopted_by field. Profile page shows read-only adopted
nodes. Node detail page has adopt/release buttons (operator adopts, admin
can release any). Admin release bypasses ownership check.
This commit is contained in:
Louis King
2026-04-30 00:07:49 +01:00
parent 6a5a23845f
commit 31418e6847
39 changed files with 1849 additions and 174 deletions
@@ -200,8 +200,9 @@ Complete list from `docker-compose.yml` collector `environment:` block:
| `OIDC_REDIRECT_URI` | (empty) | Auth |
| `OIDC_SCOPES` | `openid email profile` | Auth |
| `OIDC_ROLES_CLAIM` | `roles` | Auth |
| `OIDC_ADMIN_ROLE` | `admin` | Auth |
| `OIDC_MEMBER_ROLE` | `member` | Auth |
| `OIDC_ROLE_ADMIN` | `admin` | Auth |
| `OIDC_ROLE_OPERATOR` | `operator` | Auth |
| `OIDC_ROLE_MEMBER` | `member` | Auth |
| `OIDC_SESSION_SECRET` | (empty) | Auth |
| `OIDC_SESSION_MAX_AGE` | `86400` | Auth |
| `OIDC_COOKIE_SECURE` | `false` | Auth |
+13 -4
View File
@@ -336,6 +336,11 @@ WEB_PORT=8080
# Default: 30
# WEB_AUTO_REFRESH_SECONDS=30
# Enable debug mode in the web dashboard
# Shows extra diagnostic info (e.g., raw user IDs in the user menu)
# Default: false
# WEB_DEBUG=false
# -------------------
# OIDC Authentication
# -------------------
@@ -374,13 +379,17 @@ WEB_PORT=8080
# Default: roles
# OIDC_ROLES_CLAIM=roles
# Role value that grants admin access
# IdP role name that grants admin access
# Default: admin
# OIDC_ADMIN_ROLE=admin
# OIDC_ROLE_ADMIN=admin
# Role value that grants member access
# IdP role name for operator access (future use)
# Default: operator
# OIDC_ROLE_OPERATOR=operator
# IdP role name for member access
# Default: member
# OIDC_MEMBER_ROLE=member
# OIDC_ROLE_MEMBER=member
# Secret for signing session cookies (required when OIDC_ENABLED=true)
# Generate with: openssl rand -hex 32
+11 -4
View File
@@ -267,9 +267,12 @@ meshcore-hub/
│ │ ├── models/ # SQLAlchemy models
│ │ │ ├── node.py # Node model
│ │ │ ├── member.py # Network member model
│ │ │ ├── user_profile.py # User profile model (OIDC users)
│ │ │ ├── user_profile_node.py # User-node adoption join table
│ │ │ └── ...
│ │ └── schemas/ # Pydantic schemas
│ │ ├── members.py # Member API schemas
│ │ ├── user_profiles.py # User profile API schemas
│ │ └── ...
│ ├── collector/
│ │ ├── cli.py # Collector CLI with seed commands
@@ -289,7 +292,9 @@ meshcore-hub/
│ │ ├── metrics.py # Prometheus metrics endpoint
│ │ └── routes/ # API routes
│ │ ├── members.py # Member CRUD endpoints
│ │ └── ...
│ │ ├── user_profiles.py # User profile endpoints (GET/PUT profile)
│ │ ├── adoptions.py # Node adoption endpoints (POST adopt, DELETE release)
│ │ └── ...
│ └── web/
│ ├── cli.py
│ ├── app.py # FastAPI app
@@ -646,13 +651,15 @@ Key variables:
- `OIDC_POST_LOGOUT_REDIRECT_URI` - Post-logout redirect URI (must match IdP sign-out URIs, falls back to `OIDC_REDIRECT_URI` base)
- `OIDC_SCOPES` - OAuth scopes (default: `openid email profile`). The `openid` scope is required for ID tokens and userinfo. Quotes are stripped automatically. When using LogTo as the OIDC provider, include `roles` in `OIDC_SCOPES` (e.g., `"openid email profile roles"`) to enable role-based admin access.
- `OIDC_ROLES_CLAIM` - ID token claim for roles (default: `roles`)
- `OIDC_ADMIN_ROLE` - Role value for admin access (default: `admin`)
- `OIDC_MEMBER_ROLE` - Role value for member access (default: `member`)
- `OIDC_ROLE_ADMIN` - IdP role name for admin access (default: `admin`)
- `OIDC_ROLE_OPERATOR` - IdP role name for operator access (default: `operator`)
- `OIDC_ROLE_MEMBER` - IdP role name for member access (default: `member`)
- `OIDC_SESSION_SECRET` - Secret for signing session cookies (required if OIDC_ENABLED=true)
- `OIDC_SESSION_MAX_AGE` - Session lifetime in seconds (default: `86400`)
- `OIDC_COOKIE_SECURE` - HTTPS-only cookies (default: `false`)
- `WEB_THEME` - Default theme for the web dashboard (default: `dark`, options: `dark`, `light`). Users can override via the theme toggle in the navbar, which persists their preference in browser localStorage.
- `WEB_AUTO_REFRESH_SECONDS` - Auto-refresh interval in seconds for list pages (default: `30`, `0` to disable)
- `WEB_DEBUG` - Enable debug mode in the web dashboard (default: `false`)
- `TZ` - Timezone for web dashboard date/time display (default: `UTC`, e.g., `America/New_York`, `Europe/London`)
- `FEATURE_DASHBOARD`, `FEATURE_NODES`, `FEATURE_ADVERTISEMENTS`, `FEATURE_MESSAGES`, `FEATURE_MAP`, `FEATURE_MEMBERS`, `FEATURE_PAGES` - Feature flags to enable/disable specific web dashboard pages (default: all `true`). Dependencies: Dashboard auto-disables when all of Nodes/Advertisements/Messages are disabled. Map auto-disables when Nodes is disabled.
- `NETWORK_DOMAIN` - Network domain name (default: none)
@@ -733,7 +740,7 @@ When enabled, the collector automatically deletes event data older than the rete
| Variable | Description |
|----------|-------------|
| `NODE_CLEANUP_ENABLED` | Enable automatic cleanup of inactive nodes (default: true) |
| `NODE_CLEANUP_DAYS` | Remove nodes not seen for this many days (default: 7) |
| `NODE_CLEANUP_DAYS` | Remove nodes not seen for this many days (default: 30) |
When enabled, the collector automatically removes nodes where:
- `last_seen` is older than the configured number of days
+4 -2
View File
@@ -373,6 +373,7 @@ The collector automatically cleans up old event data and inactive nodes:
| `WEB_LOCALE` | `en` | Locale/language for the web dashboard (e.g., `en`, `es`, `fr`) |
| `WEB_DATETIME_LOCALE` | `en-US` | Locale used for date formatting in the web dashboard (e.g., `en-US` for MM/DD/YYYY, `en-GB` for DD/MM/YYYY). |
| `WEB_AUTO_REFRESH_SECONDS` | `30` | Auto-refresh interval in seconds for list pages (0 to disable) |
| `WEB_DEBUG` | `false` | Enable debug mode in the web dashboard (shows extra diagnostic info) |
| `OIDC_ENABLED` | `false` | Enable OIDC authentication for the web dashboard |
| `OIDC_CLIENT_ID` | _(none)_ | OIDC client ID (from IdP, required when OIDC_ENABLED=true) |
| `OIDC_CLIENT_SECRET` | _(none)_ | OIDC client secret (from IdP, required when OIDC_ENABLED=true) |
@@ -381,8 +382,9 @@ The collector automatically cleans up old event data and inactive nodes:
| `OIDC_POST_LOGOUT_REDIRECT_URI` | _(auto-derived)_ | Post-logout redirect URI (must match Sign-out redirect URIs in IdP). Falls back to `OIDC_REDIRECT_URI` base or `request.base_url` |
| `OIDC_SCOPES` | `openid email profile` | OAuth scopes to request. The `openid` scope is required for ID tokens. Quotes are stripped automatically. |
| `OIDC_ROLES_CLAIM` | `roles` | ID token claim name containing user roles |
| `OIDC_ADMIN_ROLE` | `admin` | Role value granting admin access |
| `OIDC_MEMBER_ROLE` | `member` | Role value granting member access |
| `OIDC_ROLE_ADMIN` | `admin` | IdP role name granting admin access |
| `OIDC_ROLE_OPERATOR` | `operator` | IdP role name for operator access (future use) |
| `OIDC_ROLE_MEMBER` | `member` | IdP role name for member access |
| `OIDC_SESSION_SECRET` | _(none)_ | Secret for signing session cookies (required when OIDC_ENABLED=true) |
| `OIDC_SESSION_MAX_AGE` | `86400` | Session cookie lifetime in seconds (default 24 hours) |
| `OIDC_COOKIE_SECURE` | `false` | HTTPS-only session cookies (enable in production) |
@@ -0,0 +1,84 @@
"""add user_profiles and user_profile_nodes tables
Revision ID: 72b6578ee3bf
Revises: a10dbca883a2
Create Date: 2026-04-29 21:52:04.028351+00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "72b6578ee3bf"
down_revision: Union[str, None] = "a10dbca883a2"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"user_profiles",
sa.Column("user_id", sa.String(length=255), nullable=False),
sa.Column("name", sa.String(length=255), nullable=True),
sa.Column("callsign", sa.String(length=20), nullable=True),
sa.Column("id", sa.String(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
with op.batch_alter_table("user_profiles", schema=None) as batch_op:
batch_op.create_index(
batch_op.f("ix_user_profiles_user_id"), ["user_id"], unique=True
)
op.create_table(
"user_profile_nodes",
sa.Column("user_profile_id", sa.String(), nullable=False),
sa.Column("node_id", sa.String(), nullable=False),
sa.Column("adopted_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["node_id"],
["nodes.id"],
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["user_profile_id"],
["user_profiles.id"],
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("user_profile_id", "node_id"),
sa.UniqueConstraint("node_id", name="uq_user_profile_nodes_node_id"),
)
with op.batch_alter_table("user_profile_nodes", schema=None) as batch_op:
batch_op.create_index(
"ix_user_profile_nodes_node_id", ["node_id"], unique=False
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("user_profile_nodes", schema=None) as batch_op:
batch_op.drop_index("ix_user_profile_nodes_node_id")
op.drop_table("user_profile_nodes")
with op.batch_alter_table("user_profiles", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_user_profiles_user_id"))
op.drop_table("user_profiles")
# ### end Alembic commands ###
+4 -2
View File
@@ -265,6 +265,7 @@ services:
- WEB_THEME=${WEB_THEME:-dark}
- WEB_LOCALE=${WEB_LOCALE:-en}
- WEB_DATETIME_LOCALE=${WEB_DATETIME_LOCALE:-en-US}
- WEB_DEBUG=${WEB_DEBUG:-false}
# OIDC authentication (see .env.example for details)
- OIDC_ENABLED=${OIDC_ENABLED:-false}
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-}
@@ -273,8 +274,9 @@ services:
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-}
- OIDC_SCOPES=${OIDC_SCOPES:-openid email profile}
- OIDC_ROLES_CLAIM=${OIDC_ROLES_CLAIM:-roles}
- OIDC_ADMIN_ROLE=${OIDC_ADMIN_ROLE:-admin}
- OIDC_MEMBER_ROLE=${OIDC_MEMBER_ROLE:-member}
- OIDC_ROLE_ADMIN=${OIDC_ROLE_ADMIN:-admin}
- OIDC_ROLE_OPERATOR=${OIDC_ROLE_OPERATOR:-operator}
- OIDC_ROLE_MEMBER=${OIDC_ROLE_MEMBER:-member}
- OIDC_SESSION_SECRET=${OIDC_SESSION_SECRET:-}
- OIDC_SESSION_MAX_AGE=${OIDC_SESSION_MAX_AGE:-86400}
- OIDC_COOKIE_SECURE=${OIDC_COOKIE_SECURE:-false}
+52 -9
View File
@@ -1,26 +1,68 @@
# OIDC Authentication
MeshCore Hub supports OpenID Connect (OIDC) for authenticating web dashboard users. When enabled, the admin interface (`/admin/`) requires an authenticated session with the `admin` role.
MeshCore Hub supports OpenID Connect (OIDC) for authenticating web dashboard users. When enabled, the web dashboard uses role-based access control to gate API endpoints through the proxy layer.
When OIDC is **disabled** (the default), no admin functionality is exposed — the admin link is hidden from the navbar and admin routes are not registered.
When OIDC is **disabled** (the default), the web proxy only allows read (GET) access to API endpoints. Write operations (POST/PUT/DELETE) and unknown endpoints are blocked. Admin operations must be performed via the CLI or direct API access with Bearer tokens.
## Architecture
OIDC is implemented entirely in the web dashboard layer. The REST API uses static Bearer token authentication independently of OIDC. The web dashboard proxies API requests and gates write operations (POST, PUT, DELETE, PATCH) by checking the user's session role.
OIDC is implemented entirely in the web dashboard layer. The REST API uses static Bearer token authentication independently of OIDC. The web dashboard proxies API requests and gates access using a per-endpoint, per-method role mapping (`ENDPOINT_ACCESS` in `web/app.py`).
```
Browser → Web Dashboard (OIDC session + API proxy) → REST API (Bearer token)
```
### Role-Based Access
User roles are read from the OIDC token's `roles` claim (configurable via `OIDC_ROLES_CLAIM`). The web proxy checks these roles against the `ENDPOINT_ACCESS` mapping to determine which API endpoints and HTTP methods each user can access.
**Defined roles:**
| Role | Config Variable | Default | Description |
|------|----------------|---------|-------------|
| Admin | `OIDC_ROLE_ADMIN` | `admin` | Full write access to all API endpoints through the proxy |
| Operator | `OIDC_ROLE_OPERATOR` | `operator` | Reserved for future use — no endpoint assignments yet |
| Member | `OIDC_ROLE_MEMBER` | `member` | Read-only access (no endpoint assignments) |
The role names are configurable to match your IdP's role naming convention. For example, if your IdP uses `superuser` instead of `admin`, set `OIDC_ROLE_ADMIN=superuser`.
Additional roles can be added to the `ENDPOINT_ACCESS` mapping in `src/meshcore_hub/web/app.py` and the corresponding `OIDC_ROLE_*` config variable.
### Endpoint Access Mapping
The proxy uses a hardcoded per-endpoint, per-method mapping in `src/meshcore_hub/web/app.py`:
| Path prefix | Method | Access |
|-------------|--------|--------|
| `v1/nodes` | GET | Open |
| `v1/nodes/` | GET | Open |
| `v1/nodes/` | POST, PUT, DELETE | `admin` |
| `v1/members` | GET | Open |
| `v1/members` | POST, PUT, DELETE | `admin` |
| `v1/messages` | GET | Open |
| `v1/advertisements` | GET | Open |
| `v1/dashboard` | GET | Open |
| `v1/trace-paths` | GET | Open |
| `v1/telemetry` | GET | Open |
- **Open** = no authentication required (anonymous OK, works with or without OIDC)
- **`admin`** = requires OIDC enabled + user has the `admin` role
- Method not listed for a matched prefix = denied
- No prefix match = denied
### Client-Side Role Checks
The SPA receives the user's roles array in `window.__APP_CONFIG__.roles`. Client-side pages use the `hasRole(roleName)` helper, which returns `true` when OIDC is disabled (open access) or when the user has the specified role.
## Login Flow
1. User visits `/admin/` — no session exists — server redirects to `/auth/login?next=/admin/`
2. `/auth/login` stores the `next` URL in the session and redirects to the IdP
3. User authenticates at the IdP, which redirects back to `/auth/callback?code=...`
4. `/auth/callback` exchanges the authorization code for tokens, extracts userinfo and roles, stores them in a session cookie, and redirects to the `next` URL
5. The SPA receives `window.__APP_CONFIG__` with `oidc_enabled`, `user`, `is_member`, and `is_admin` flags
6. Admin routes are registered client-side only when `is_admin` is `true`
7. Write operations through the API proxy are blocked for non-admin sessions (HTTP 403)
5. The SPA receives `window.__APP_CONFIG__` with `oidc_enabled`, `user`, and `roles` flags
6. Admin routes are registered client-side only when `hasRole('admin')` is `true`
7. Write operations through the API proxy are checked against the `ENDPOINT_ACCESS` mapping
8. Logout (`/auth/logout`) clears the session and redirects to the IdP's end-session endpoint
## Configuration
@@ -37,8 +79,9 @@ All OIDC settings are environment variables. Set `OIDC_ENABLED=true` to activate
| `OIDC_POST_LOGOUT_REDIRECT_URI` | _(auto)_ | Post-logout redirect URI (falls back to `OIDC_REDIRECT_URI` base or request URL) |
| `OIDC_SCOPES` | `openid email profile` | OAuth scopes to request. The `openid` scope is required. Quotes are stripped automatically. |
| `OIDC_ROLES_CLAIM` | `roles` | ID token claim name containing user roles |
| `OIDC_ADMIN_ROLE` | `admin` | Role value that grants admin access |
| `OIDC_MEMBER_ROLE` | `member` | Role value that grants member access |
| `OIDC_ROLE_ADMIN` | `admin` | IdP role name that grants admin access |
| `OIDC_ROLE_OPERATOR` | `operator` | IdP role name for operator access (future use) |
| `OIDC_ROLE_MEMBER` | `member` | IdP role name for member access |
| `OIDC_SESSION_SECRET` | _(none)_ | Secret for signing session cookies (generate with `openssl rand -hex 32`) |
| `OIDC_SESSION_MAX_AGE` | `86400` | Session cookie lifetime in seconds (default: 24 hours) |
| `OIDC_COOKIE_SECURE` | `false` | Set to `true` to require HTTPS for session cookies |
@@ -79,4 +122,4 @@ OIDC_SCOPES="openid email profile roles"
**Role assignment:**
Create `admin` and/or `member` roles in LogTo and assign them to users. The role names must match `OIDC_ADMIN_ROLE` and `OIDC_MEMBER_ROLE` (both default to `admin` and `member` respectively).
Create an `admin` role in LogTo and assign it to users who should have write access through the web dashboard. The role name must match `OIDC_ROLE_ADMIN` (default: `admin`). If your LogTo setup uses a different role name, set `OIDC_ROLE_ADMIN` accordingly.
+27 -1
View File
@@ -265,11 +265,19 @@ Dashboard page content:
### 9. `nodes`
Node-specific labels:
Node detail page labels:
| Key | English | Context |
|-----|---------|---------|
| `scan_to_add` | Scan to add as contact | QR code instruction |
| `ownership` | Ownership | Adoption card heading on node detail page |
| `adopt` | Adopt | Adopt button (operator/admin only) |
| `release` | Release | Release button (owner or admin only) |
| `adopted_by` | Adopted by {{name}} | Display name of adopting user ({{name}} = user name or ID) |
| `not_adopted` | This node has not been adopted by any operator. | Shown when node is unadopted and user is operator/admin |
| `adopt_success` | Node adopted successfully | Flash message after adopting |
| `release_success` | Node released successfully | Flash message after releasing |
| `release_confirm` | Are you sure you want to release this node? | Confirmation dialog for release |
### 10. `advertisements`
@@ -405,6 +413,24 @@ Footer content:
|-----|---------|---------|
| `powered_by` | Powered by | "Powered by" attribution |
### 20. `user_profile`
User profile page (OIDC authenticated users):
| Key | English | Context |
|-----|---------|---------|
| `title` | Profile | Page heading |
| `your_profile` | Your Profile | Profile card heading |
| `profile_updated` | Profile updated successfully | Flash message after save |
| `save_profile` | Save Profile | Save button label |
| `name_label` | Display Name | Form label |
| `name_placeholder` | Your name or preferred name | Input placeholder |
| `callsign_label` | Callsign | Form label |
| `callsign_placeholder` | Amateur radio callsign (e.g., W1ABC) | Input placeholder |
| `adopted_nodes` | Adopted Nodes | Adopted nodes card heading |
| `no_adopted_nodes` | No adopted nodes | Empty state |
| `login_to_view` | Log in to view your profile | Unauthenticated notice |
## Translation Tips
1. **Preserve HTML tags:** Some strings contain `<code>`, `<strong>`, or `<br/>` tags - keep these intact.
+88 -4
View File
@@ -4,7 +4,7 @@ This guide covers upgrading from a previous MeshCore Hub release to the current
## v0.10.0
This release includes **breaking changes** to the admin authentication model.
This release includes **breaking changes** to the admin authentication model, OIDC role configuration, and adds user profiles with node adoption.
### Overview of Changes
@@ -12,9 +12,18 @@ This release includes **breaking changes** to the admin authentication model.
|------|--------|-------|
| Admin auth | `WEB_ADMIN_ENABLED=true` (open access) | OIDC/OAuth2 authentication via identity provider |
| Auth library | None | Authlib (`authlib>=1.3.0`) |
| Admin access | Anyone with the URL | Authenticated users with `admin` role from IdP |
| Admin access | Anyone with the URL | Authenticated users with roles from IdP |
| Session mgmt | None | Starlette `SessionMiddleware` (signed cookies) |
| Write gating | None (API proxy open) | POST/PUT/DELETE/PATCH require admin session when OIDC enabled |
| Proxy gating | None (API proxy open) | Per-endpoint, per-method role-based access mapping |
| Role config | None | `OIDC_ROLE_ADMIN`, `OIDC_ROLE_OPERATOR`, `OIDC_ROLE_MEMBER` env vars |
| SPA config | `is_admin: bool`, `is_member: bool` | `roles: ["admin", "member"]` array + `role_names` mapping |
| Client-side | `config.is_admin` checks | `hasRole("admin")` helper |
| OIDC disabled | All proxy access open | Only read access to known endpoints; writes blocked |
| User profiles | None | `user_profiles` table (auto-created on first access) |
| Node adoption | None | `user_profile_nodes` join table (operator role required) |
| Node cleanup | 7 days default | 30 days default |
| API proxy | No user identity forwarding | Injects `X-User-Id` and `X-User-Roles` headers |
| Profile page | None | `/profile` SPA page linked from auth dropdown |
### Migration Steps
@@ -28,7 +37,15 @@ This release includes **breaking changes** to the admin authentication model.
OIDC_SESSION_SECRET=$(openssl rand -hex 32)
```
3. **Remove `WEB_ADMIN_ENABLED`** from your `.env` (no longer used)
4. **Configure roles** in your IdP: assign `admin` and/or `member` roles to users
4. **Remove `OIDC_ADMIN_ROLE` and `OIDC_MEMBER_ROLE`** from your `.env` if present (renamed, see below)
5. **Configure roles** in your IdP and set the role name env vars to match:
```bash
# These defaults match common IdP setups — only change if your IdP uses different role names
OIDC_ROLE_ADMIN=admin
OIDC_ROLE_OPERATOR=operator
OIDC_ROLE_MEMBER=member
```
6. **Test admin access** — confirm that admin users can access `/admin/` and perform write operations
### Removed Variables
@@ -36,12 +53,79 @@ This release includes **breaking changes** to the admin authentication model.
|----------|--------|
| `WEB_ADMIN_ENABLED` | Replaced by `OIDC_ENABLED` |
### Renamed Variables
| Old Variable | New Variable | Notes |
|--------------|-------------|-------|
| `OIDC_ADMIN_ROLE` | `OIDC_ROLE_ADMIN` | New naming convention (`OIDC_ROLE_<NAME>`) |
| `OIDC_MEMBER_ROLE` | `OIDC_ROLE_MEMBER` | New naming convention |
### New Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `OIDC_ROLE_ADMIN` | `admin` | IdP role name granting admin access |
| `OIDC_ROLE_OPERATOR` | `operator` | IdP role name for operator access (future use) |
| `OIDC_ROLE_MEMBER` | `member` | IdP role name for member access |
See the OIDC section in `.env.example` for the full list of environment variables.
### Behavior Change: OIDC Disabled
When OIDC is disabled, the web proxy now only allows GET access to known API endpoints. Write operations (POST/PUT/DELETE) are blocked, even without OIDC. If you relied on open write access through the web proxy without OIDC, use the CLI or direct API access with Bearer tokens instead.
**Important for LogTo users:** You must pass `client_id` in the logout request for the post-logout redirect to work. This is handled automatically by the application. You also need to register your app's URL as a **Sign-out redirect URI** in the LogTo admin console (e.g. `https://ipnt.uk`). If the redirect still doesn't work after updating, set `OIDC_POST_LOGOUT_REDIRECT_URI` explicitly to match your registered URI.
### User Profiles and Node Adoption
Authenticated OIDC users now have a profile page at `/profile` (linked from the avatar dropdown menu). Profiles are auto-created on first access with blank name and callsign fields.
Users with the **operator** role can adopt (claim) mesh network nodes from the node detail page. Users with the **admin** role can release any adopted node. Adopted nodes are shown as a read-only list on the profile page and display the adopting user's name on the node detail page.
### New API Endpoints
| Endpoint | Method | Auth | Description |
|----------|--------|------|-------------|
| `/api/v1/user/profile/{user_id}` | GET | Any OIDC user (own profile only) | Get-or-create profile with adopted nodes |
| `/api/v1/user/profile/{user_id}` | PUT | Any OIDC user (own profile only) | Update name/callsign |
| `/api/v1/adoptions` | POST | Operator or Admin | Adopt a node (auto-creates profile if needed) |
| `/api/v1/adoptions/{public_key}` | DELETE | Operator (own node) or Admin (any) | Release a node |
The `NodeRead` schema now includes an `adopted_by` field with the adopting user's `user_id`, `name`, and `callsign` (or `null` if not adopted).
The web proxy injects `X-User-Id` and `X-User-Roles` headers when forwarding API requests for authenticated users, enabling the API layer to enforce per-user access control.
### New Database Tables
The database migration creates two new tables:
**`user_profiles`**: Stores OIDC user profile data (auto-created on first access).
| Column | Type | Description |
|--------|------|-------------|
| `id` | VARCHAR(36) PK | UUID |
| `user_id` | VARCHAR(255) UNIQUE | OIDC `sub` claim |
| `name` | VARCHAR(255) | Display name (blank initially) |
| `callsign` | VARCHAR(20) | Radio callsign (blank initially) |
| `created_at` | DATETIME | Auto |
| `updated_at` | DATETIME | Auto |
**`user_profile_nodes`**: Join table linking users to adopted nodes. Foreign keys have `ON DELETE CASCADE` so node cleanup automatically removes stale adoption records.
| Column | Type | Description |
|--------|------|-------------|
| `user_profile_id` | VARCHAR(36) FK PK | References `user_profiles.id` (CASCADE) |
| `node_id` | VARCHAR(36) FK PK UNIQUE | References `nodes.id` (CASCADE) |
| `adopted_at` | DATETIME | When the adoption occurred |
### Default Change: Node Cleanup
The default value for `NODE_CLEANUP_DAYS` has changed from **7 days** to **30 days**. If you previously relied on the 7-day default, set it explicitly in your `.env`:
```bash
NODE_CLEANUP_DAYS=7
```
## v0.9.0
This release includes **breaking changes** to the MQTT broker, packet capture service, data ingestion pipeline, and public key handling.
+113
View File
@@ -12,6 +12,10 @@ logger = logging.getLogger(__name__)
# Security scheme
security = HTTPBearer(auto_error=False)
# Header constants for proxy-injected user identity
X_USER_ID_HEADER = "X-User-Id"
X_USER_ROLES_HEADER = "X-User-Roles"
def get_api_keys(request: Request) -> tuple[str | None, str | None]:
"""Get API keys from app state.
@@ -139,3 +143,112 @@ async def require_admin(
# Dependency types for use in routes
RequireRead = Annotated[str | None, Depends(require_read)]
RequireAdmin = Annotated[str, Depends(require_admin)]
async def require_user_owner(
request: Request,
token: Annotated[str | None, Depends(get_current_token)],
) -> str:
"""Require an authenticated user identity via X-User-Id header.
The web proxy injects X-User-Id when an OIDC user is authenticated.
The header is trusted because only the proxy has the API key.
Returns:
The user_id string from the X-User-Id header.
Raises:
HTTPException: 401 if no valid API key or no X-User-Id header.
"""
read_key, admin_key = get_api_keys(request)
if read_key or admin_key:
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
headers={"WWW-Authenticate": "Bearer"},
)
key_valid = (read_key and hmac.compare_digest(token, read_key)) or (
admin_key and hmac.compare_digest(token, admin_key)
)
if not key_valid:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
headers={"WWW-Authenticate": "Bearer"},
)
user_id = request.headers.get(X_USER_ID_HEADER)
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User identity required",
)
return user_id
async def require_operator(
request: Request,
token: Annotated[str | None, Depends(get_current_token)],
) -> tuple[str, list[str]]:
"""Require an authenticated user with the operator role.
Checks X-User-Roles header (comma-separated) for the operator role.
Also validates API key and X-User-Id header.
Returns:
Tuple of (user_id, roles_list).
Raises:
HTTPException: 401 if not authenticated, 403 if not operator.
"""
user_id = await require_user_owner(request, token)
roles_header = request.headers.get(X_USER_ROLES_HEADER, "")
roles = [r.strip() for r in roles_header.split(",") if r.strip()]
operator_role = getattr(request.app.state, "oidc_role_operator", "operator")
if operator_role not in roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Operator role required",
)
return user_id, roles
async def require_operator_or_admin(
request: Request,
token: Annotated[str | None, Depends(get_current_token)],
) -> tuple[str, list[str]]:
"""Require an authenticated user with operator or admin role.
Returns:
Tuple of (user_id, roles_list).
Raises:
HTTPException: 401 if not authenticated, 403 if not operator or admin.
"""
user_id = await require_user_owner(request, token)
roles_header = request.headers.get(X_USER_ROLES_HEADER, "")
roles = [r.strip() for r in roles_header.split(",") if r.strip()]
operator_role = getattr(request.app.state, "oidc_role_operator", "operator")
admin_role = getattr(request.app.state, "oidc_role_admin", "admin")
if operator_role not in roles and admin_role not in roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Operator or admin role required",
)
return user_id, roles
RequireUserOwner = Annotated[str, Depends(require_user_owner)]
RequireOperator = Annotated[tuple[str, list[str]], Depends(require_operator)]
RequireOperatorOrAdmin = Annotated[
tuple[str, list[str]], Depends(require_operator_or_admin)
]
+4
View File
@@ -10,6 +10,8 @@ from meshcore_hub.api.routes.trace_paths import router as trace_paths_router
from meshcore_hub.api.routes.telemetry import router as telemetry_router
from meshcore_hub.api.routes.dashboard import router as dashboard_router
from meshcore_hub.api.routes.members import router as members_router
from meshcore_hub.api.routes.user_profiles import router as user_profiles_router
from meshcore_hub.api.routes.adoptions import router as adoptions_router
api_router = APIRouter()
@@ -26,3 +28,5 @@ api_router.include_router(
api_router.include_router(telemetry_router, prefix="/telemetry", tags=["Telemetry"])
api_router.include_router(dashboard_router, prefix="/dashboard", tags=["Dashboard"])
api_router.include_router(members_router, prefix="/members", tags=["Members"])
api_router.include_router(user_profiles_router, prefix="/user", tags=["User"])
api_router.include_router(adoptions_router, prefix="/adoptions", tags=["Adoptions"])
+140
View File
@@ -0,0 +1,140 @@
"""Node adoption API routes."""
import logging
from fastapi import APIRouter, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from meshcore_hub.api.auth import RequireOperatorOrAdmin
from meshcore_hub.api.dependencies import DbSession
from meshcore_hub.common.models import Node, UserProfile, UserProfileNode
from meshcore_hub.common.schemas.user_profiles import AdoptedNodeRead, NodeAdoptRequest
logger = logging.getLogger(__name__)
router = APIRouter()
def _get_or_create_profile(session: DbSession, user_id: str) -> UserProfile:
"""Get existing profile or create a new blank one."""
query = select(UserProfile).where(UserProfile.user_id == user_id)
profile = session.execute(query).scalar_one_or_none()
if profile:
return profile
profile = UserProfile(user_id=user_id)
session.add(profile)
session.commit()
session.refresh(profile)
logger.info("Created new user profile for user_id=%s", user_id)
return profile
@router.post("", response_model=AdoptedNodeRead, status_code=201)
async def adopt_node(
adopt_request: NodeAdoptRequest,
caller_info: RequireOperatorOrAdmin,
session: DbSession,
) -> AdoptedNodeRead:
"""Adopt a node. Requires operator or admin role."""
caller_id, _ = caller_info
profile = _get_or_create_profile(session, caller_id)
public_key = adopt_request.public_key.lower()
node_query = select(Node).where(Node.public_key == public_key)
node = session.execute(node_query).scalar_one_or_none()
if not node:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Node with public_key '{public_key}' not found",
)
existing_query = select(UserProfileNode).where(UserProfileNode.node_id == node.id)
existing = session.execute(existing_query).scalar_one_or_none()
if existing:
if existing.user_profile_id == profile.id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Node already adopted by this user",
)
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Node already adopted by another user",
)
association = UserProfileNode(
user_profile_id=profile.id,
node_id=node.id,
)
session.add(association)
session.commit()
session.refresh(association)
logger.info(
"User %s adopted node %s",
caller_id,
public_key[:12],
)
return AdoptedNodeRead(
public_key=node.public_key,
name=node.name,
adv_type=node.adv_type,
adopted_at=association.adopted_at,
)
@router.delete("/{public_key}", status_code=204)
async def release_node(
public_key: str,
caller_info: RequireOperatorOrAdmin,
request: Request,
session: DbSession,
) -> None:
"""Release (unadopt) a node.
Operators can only release their own adopted nodes.
Admins can release any node.
"""
caller_id, roles = caller_info
admin_role = getattr(request.app.state, "oidc_role_admin", "admin")
is_admin = admin_role in roles
normalized_key = public_key.lower()
node_query = select(Node).where(Node.public_key == normalized_key)
node = session.execute(node_query).scalar_one_or_none()
if not node:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Node with public_key '{public_key}' not found",
)
assoc_query = (
select(UserProfileNode)
.where(UserProfileNode.node_id == node.id)
.options(selectinload(UserProfileNode.user_profile))
)
association = session.execute(assoc_query).scalar_one_or_none()
if not association:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Node is not adopted",
)
if not is_admin and association.user_profile.user_id != caller_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the adopting user or an admin can release this node",
)
session.delete(association)
session.commit()
logger.info(
"User %s released node %s",
caller_id,
normalized_key[:12],
)
+43 -9
View File
@@ -8,12 +8,31 @@ from sqlalchemy.orm import selectinload
from meshcore_hub.api.auth import RequireRead
from meshcore_hub.api.dependencies import DbSession
from meshcore_hub.common.models import Node, NodeTag
from meshcore_hub.common.schemas.nodes import NodeList, NodeRead
from meshcore_hub.common.models import Node, NodeTag, UserProfileNode
from meshcore_hub.common.schemas.nodes import AdoptedByUser, NodeList, NodeRead
router = APIRouter()
def _get_adopted_by(node: Node) -> Optional[AdoptedByUser]:
"""Extract adopted_by info from a node's eager-loaded associations."""
if node.user_profile_associations:
profile = node.user_profile_associations[0].user_profile
return AdoptedByUser(
user_id=profile.user_id,
name=profile.name,
callsign=profile.callsign,
)
return None
def _node_to_read(node: Node) -> NodeRead:
"""Convert a Node ORM object to NodeRead schema with adopted_by."""
node_read = NodeRead.model_validate(node)
node_read.adopted_by = _get_adopted_by(node)
return node_read
@router.get("", response_model=NodeList)
async def list_nodes(
_: RequireRead,
@@ -28,8 +47,13 @@ async def list_nodes(
offset: int = Query(0, ge=0, description="Page offset"),
) -> NodeList:
"""List all nodes with pagination and filtering."""
# Build base query with tags loaded
query = select(Node).options(selectinload(Node.tags))
# Build base query with tags and adoption info loaded
query = select(Node).options(
selectinload(Node.tags),
selectinload(Node.user_profile_associations).selectinload(
UserProfileNode.user_profile
),
)
if search:
# Search in public key, node name, or name tag
@@ -113,7 +137,7 @@ async def list_nodes(
nodes = session.execute(query).scalars().all()
return NodeList(
items=[NodeRead.model_validate(n) for n in nodes],
items=[_node_to_read(n) for n in nodes],
total=total,
limit=limit,
offset=offset,
@@ -132,7 +156,12 @@ async def get_node_by_prefix(
"""
query = (
select(Node)
.options(selectinload(Node.tags))
.options(
selectinload(Node.tags),
selectinload(Node.user_profile_associations).selectinload(
UserProfileNode.user_profile
),
)
.where(Node.public_key.startswith(prefix))
.order_by(Node.public_key)
.limit(1)
@@ -142,7 +171,7 @@ async def get_node_by_prefix(
if not node:
raise HTTPException(status_code=404, detail="Node not found")
return NodeRead.model_validate(node)
return _node_to_read(node)
@router.get("/{public_key}", response_model=NodeRead)
@@ -154,7 +183,12 @@ async def get_node(
"""Get a single node by exact public key match."""
query = (
select(Node)
.options(selectinload(Node.tags))
.options(
selectinload(Node.tags),
selectinload(Node.user_profile_associations).selectinload(
UserProfileNode.user_profile
),
)
.where(Node.public_key == public_key)
)
node = session.execute(query).scalar_one_or_none()
@@ -162,4 +196,4 @@ async def get_node(
if not node:
raise HTTPException(status_code=404, detail="Node not found")
return NodeRead.model_validate(node)
return _node_to_read(node)
@@ -0,0 +1,98 @@
"""User profile API routes."""
import logging
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select
from meshcore_hub.api.auth import RequireUserOwner
from meshcore_hub.api.dependencies import DbSession
from meshcore_hub.common.models import UserProfile
from meshcore_hub.common.schemas.user_profiles import (
AdoptedNodeRead,
UserProfileRead,
UserProfileUpdate,
UserProfileWithNodes,
)
logger = logging.getLogger(__name__)
router = APIRouter()
def _verify_owner(user_id: str, requested_id: str) -> None:
"""Verify the authenticated user matches the requested user_id."""
if user_id != requested_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied: cannot access another user's profile",
)
def _get_or_create_profile(session: DbSession, user_id: str) -> UserProfile:
"""Get existing profile or create a new blank one."""
query = select(UserProfile).where(UserProfile.user_id == user_id)
profile = session.execute(query).scalar_one_or_none()
if profile:
return profile
profile = UserProfile(user_id=user_id)
session.add(profile)
session.commit()
session.refresh(profile)
logger.info("Created new user profile for user_id=%s", user_id)
return profile
@router.get("/profile/{user_id}", response_model=UserProfileWithNodes)
async def get_profile(
user_id: str,
caller_id: RequireUserOwner,
session: DbSession,
) -> UserProfileWithNodes:
"""Get or create a user profile. Auto-creates on first access."""
_verify_owner(caller_id, user_id)
profile = _get_or_create_profile(session, user_id)
adopted_nodes = []
for assoc in profile.node_associations:
adopted_nodes.append(
AdoptedNodeRead(
public_key=assoc.node.public_key,
name=assoc.node.name,
adv_type=assoc.node.adv_type,
adopted_at=assoc.adopted_at,
)
)
return UserProfileWithNodes(
id=profile.id,
user_id=profile.user_id,
name=profile.name,
callsign=profile.callsign,
created_at=profile.created_at,
updated_at=profile.updated_at,
nodes=adopted_nodes,
)
@router.put("/profile/{user_id}", response_model=UserProfileRead)
async def update_profile(
user_id: str,
profile_update: UserProfileUpdate,
caller_id: RequireUserOwner,
session: DbSession,
) -> UserProfileRead:
"""Update a user profile."""
_verify_owner(caller_id, user_id)
profile = _get_or_create_profile(session, user_id)
if profile_update.name is not None:
profile.name = profile_update.name
if profile_update.callsign is not None:
profile.callsign = profile_update.callsign
session.commit()
session.refresh(profile)
return UserProfileRead.model_validate(profile)
+12 -6
View File
@@ -134,7 +134,7 @@ class CollectorSettings(CommonSettings):
default=True, description="Enable automatic cleanup of inactive nodes"
)
node_cleanup_days: int = Field(
default=7,
default=30,
description="Remove nodes not seen for this many days (last_seen)",
ge=1,
)
@@ -277,7 +277,10 @@ class WebSettings(CommonSettings):
web_auto_refresh_seconds: int = Field(
default=30,
description="Auto-refresh interval in seconds for list pages (0 to disable)",
ge=0,
)
web_debug: bool = Field(
default=False,
description="Enable debug mode in the web dashboard",
)
# OIDC / OAuth2 authentication
@@ -306,11 +309,14 @@ class WebSettings(CommonSettings):
oidc_roles_claim: str = Field(
default="roles", description="ID token claim containing user roles"
)
oidc_admin_role: str = Field(
default="admin", description="Role value granting admin access"
oidc_role_admin: str = Field(
default="admin", description="IdP role name for admin access"
)
oidc_member_role: str = Field(
default="member", description="Role value granting member access"
oidc_role_operator: str = Field(
default="operator", description="IdP role name for operator access"
)
oidc_role_member: str = Field(
default="member", description="IdP role name for member access"
)
oidc_session_secret: Optional[str] = Field(
default=None, description="Secret key for signing session cookies"
@@ -9,6 +9,8 @@ from meshcore_hub.common.models.trace_path import TracePath
from meshcore_hub.common.models.telemetry import Telemetry
from meshcore_hub.common.models.event_log import EventLog
from meshcore_hub.common.models.member import Member
from meshcore_hub.common.models.user_profile import UserProfile
from meshcore_hub.common.models.user_profile_node import UserProfileNode
from meshcore_hub.common.models.event_observer import EventObserver, add_event_observer
__all__ = [
@@ -22,6 +24,8 @@ __all__ = [
"Telemetry",
"EventLog",
"Member",
"UserProfile",
"UserProfileNode",
"EventObserver",
"add_event_observer",
]
+7
View File
@@ -10,6 +10,7 @@ from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin, utc
if TYPE_CHECKING:
from meshcore_hub.common.models.node_tag import NodeTag
from meshcore_hub.common.models.user_profile_node import UserProfileNode
class Node(Base, UUIDMixin, TimestampMixin):
@@ -81,6 +82,12 @@ class Node(Base, UUIDMixin, TimestampMixin):
cascade="all, delete-orphan",
lazy="selectin",
)
user_profile_associations: Mapped[list["UserProfileNode"]] = relationship(
"UserProfileNode",
back_populates="node",
cascade="all, delete-orphan",
lazy="selectin",
)
__table_args__ = (
Index("ix_nodes_last_seen", "last_seen"),
@@ -0,0 +1,54 @@
"""UserProfile model for authenticated user profiles."""
from typing import TYPE_CHECKING, Optional
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin
if TYPE_CHECKING:
from meshcore_hub.common.models.user_profile_node import UserProfileNode
class UserProfile(Base, UUIDMixin, TimestampMixin):
"""UserProfile model for authenticated OIDC users.
Stores profile information for users who authenticate via OIDC.
Profiles are auto-created lazily on first access.
Attributes:
id: UUID primary key
user_id: OIDC subject identifier (unique, from IdP 'sub' claim)
name: User's display name or preferred name (blank initially)
callsign: Amateur radio callsign (blank initially)
created_at: Record creation timestamp
updated_at: Record update timestamp
"""
__tablename__ = "user_profiles"
user_id: Mapped[str] = mapped_column(
String(255),
unique=True,
nullable=False,
index=True,
)
name: Mapped[Optional[str]] = mapped_column(
String(255),
nullable=True,
)
callsign: Mapped[Optional[str]] = mapped_column(
String(20),
nullable=True,
)
node_associations: Mapped[list["UserProfileNode"]] = relationship(
"UserProfileNode",
back_populates="user_profile",
cascade="all, delete-orphan",
lazy="selectin",
)
def __repr__(self) -> str:
return f"<UserProfile(id={self.id}, user_id={self.user_id}, name={self.name})>"
@@ -0,0 +1,67 @@
"""UserProfileNode association model for user-to-node adoption."""
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, Index, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from meshcore_hub.common.models.base import Base
if TYPE_CHECKING:
from meshcore_hub.common.models.node import Node
from meshcore_hub.common.models.user_profile import UserProfile
def _utc_now() -> datetime:
return datetime.now(timezone.utc)
class UserProfileNode(Base):
"""Association table linking UserProfiles to Nodes (adoption).
A node can be adopted by at most one user (enforced by unique constraint
on node_id). A user can adopt zero or more nodes.
Attributes:
user_profile_id: FK to user_profiles.id (part of composite PK)
node_id: FK to nodes.id (part of composite PK, also unique)
adopted_at: Timestamp when the adoption occurred
"""
__tablename__ = "user_profile_nodes"
user_profile_id: Mapped[str] = mapped_column(
ForeignKey("user_profiles.id", ondelete="CASCADE"),
primary_key=True,
)
node_id: Mapped[str] = mapped_column(
ForeignKey("nodes.id", ondelete="CASCADE"),
primary_key=True,
)
adopted_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=_utc_now,
nullable=False,
)
user_profile: Mapped["UserProfile"] = relationship(
"UserProfile",
back_populates="node_associations",
)
node: Mapped["Node"] = relationship(
"Node",
back_populates="user_profile_associations",
)
__table_args__ = (
UniqueConstraint("node_id", name="uq_user_profile_nodes_node_id"),
Index("ix_user_profile_nodes_node_id", "node_id"),
)
def __repr__(self) -> str:
return (
f"<UserProfileNode("
f"user_profile_id={self.user_profile_id}, "
f"node_id={self.node_id})>"
)
+14
View File
@@ -72,6 +72,17 @@ class NodeTagRead(BaseModel):
from_attributes = True
class AdoptedByUser(BaseModel):
"""Schema for the user who has adopted a node."""
user_id: str = Field(..., description="OIDC subject identifier")
name: Optional[str] = Field(default=None, description="User display name")
callsign: Optional[str] = Field(default=None, description="Amateur radio callsign")
class Config:
from_attributes = True
class NodeRead(BaseModel):
"""Schema for reading a node."""
@@ -88,6 +99,9 @@ class NodeRead(BaseModel):
created_at: datetime = Field(..., description="Record creation timestamp")
updated_at: datetime = Field(..., description="Record update timestamp")
tags: list[NodeTagRead] = Field(default_factory=list, description="Node tags")
adopted_by: Optional[AdoptedByUser] = Field(
default=None, description="User who has adopted this node"
)
class Config:
from_attributes = True
@@ -0,0 +1,67 @@
"""Pydantic schemas for user profile API endpoints."""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class UserProfileRead(BaseModel):
"""Schema for reading a user profile."""
id: str = Field(..., description="Profile UUID")
user_id: str = Field(..., description="OIDC subject identifier")
name: Optional[str] = Field(default=None, description="User's display name")
callsign: Optional[str] = Field(default=None, description="Amateur radio callsign")
created_at: datetime = Field(..., description="Creation timestamp")
updated_at: datetime = Field(..., description="Last update timestamp")
class Config:
from_attributes = True
class UserProfileUpdate(BaseModel):
"""Schema for updating a user profile."""
name: Optional[str] = Field(
default=None,
min_length=1,
max_length=255,
description="User's display name",
)
callsign: Optional[str] = Field(
default=None,
max_length=20,
description="Amateur radio callsign",
)
class AdoptedNodeRead(BaseModel):
"""Schema for reading an adopted node in the context of a user profile."""
public_key: str = Field(..., description="Node's 64-character hex public key")
name: Optional[str] = Field(default=None, description="Node display name")
adv_type: Optional[str] = Field(default=None, description="Advertisement type")
adopted_at: datetime = Field(..., description="When the node was adopted")
class Config:
from_attributes = True
class UserProfileWithNodes(UserProfileRead):
"""Schema for reading a user profile with adopted nodes."""
nodes: list[AdoptedNodeRead] = Field(
default_factory=list,
description="Nodes adopted by this user",
)
class NodeAdoptRequest(BaseModel):
"""Schema for adopting a node."""
public_key: str = Field(
...,
max_length=64,
description="Public key of the node to adopt",
)
+143 -35
View File
@@ -24,8 +24,8 @@ from meshcore_hub.common.i18n import load_locale, t
from meshcore_hub.common.schemas import RadioConfig
from meshcore_hub.web.middleware import CacheControlMiddleware
from meshcore_hub.web.oidc import (
get_session_roles,
get_session_user,
get_user_roles,
init_oidc,
oauth,
strip_userinfo,
@@ -41,6 +41,98 @@ TEMPLATES_DIR = PACKAGE_DIR / "templates"
STATIC_DIR = PACKAGE_DIR / "static"
# Per-endpoint, per-method role access mapping for the API proxy.
# Key: URL path prefix (after /api/), Value: {method -> allowed roles}.
# _OPEN = unconditional access (OIDC on or off, anonymous OK).
# Method not listed = denied. No prefix match = denied.
_OPEN: frozenset[str] = frozenset()
def _build_endpoint_access(
role_admin: str,
role_operator: str = "operator",
role_member: str = "member",
) -> dict[str, dict[str, frozenset[str]]]:
"""Build the per-endpoint access mapping using configured role names.
Args:
role_admin: The IdP role name that grants admin access.
role_operator: The IdP role name that grants operator access.
role_member: The IdP role name that grants member access.
Returns:
Endpoint access mapping dict.
"""
admin = frozenset({role_admin})
any_authenticated = frozenset({role_admin, role_operator, role_member})
operator_admin = frozenset({role_admin, role_operator})
return {
"v1/nodes": {
"GET": _OPEN,
},
"v1/nodes/": {
"GET": _OPEN,
"POST": admin,
"PUT": admin,
"DELETE": admin,
},
"v1/members": {
"GET": _OPEN,
"POST": admin,
"PUT": admin,
"DELETE": admin,
},
"v1/messages": {
"GET": _OPEN,
},
"v1/advertisements": {
"GET": _OPEN,
},
"v1/dashboard": {
"GET": _OPEN,
},
"v1/trace-paths": {
"GET": _OPEN,
},
"v1/telemetry": {
"GET": _OPEN,
},
"v1/adoptions": {
"POST": operator_admin,
"DELETE": operator_admin,
},
"v1/user/profile": {
"GET": any_authenticated,
"PUT": any_authenticated,
},
}
def check_api_access(
path: str,
method: str,
oidc_enabled: bool,
user_roles: frozenset[str],
mapping: dict[str, dict[str, frozenset[str]]],
) -> bool:
"""Check if user has required role for the given API path + method.
Longest prefix wins. Method must be explicitly listed.
_OPEN means unconditional access. Specific roles require OIDC on + role match.
"""
for prefix in sorted(mapping, key=len, reverse=True):
if path.startswith(prefix):
required = mapping[prefix].get(method)
if required is None:
return False
if not required:
return True
if not oidc_enabled:
return False
return bool(user_roles & required)
return False
def _parse_decoder_key_entries(raw: str | None) -> list[str]:
"""Parse COLLECTOR_CHANNEL_KEYS into key entries."""
if not raw:
@@ -182,28 +274,30 @@ def _build_config_json(app: FastAPI, request: Request) -> str:
"auto_refresh_seconds": app.state.auto_refresh_seconds,
"channel_labels": app.state.channel_labels,
"logo_invert_light": app.state.logo_invert_light,
"debug": app.state.web_debug,
}
role_names = {
"admin": app.state.oidc_role_admin,
"operator": app.state.oidc_role_operator,
"member": app.state.oidc_role_member,
}
if getattr(app.state, "oidc_enabled", False):
user = get_session_user(request)
is_member, is_admin = get_user_roles(
request,
app.state.oidc_roles_claim,
app.state.oidc_admin_role,
app.state.oidc_member_role,
)
roles = get_session_roles(request, app.state.oidc_roles_claim)
config.update(
oidc_enabled=True,
user=user,
is_member=is_member,
is_admin=is_admin,
roles=roles,
role_names=role_names,
)
else:
config.update(
oidc_enabled=False,
user=None,
is_member=False,
is_admin=False,
roles=[],
role_names=role_names,
)
# Escape "</script>" sequences to prevent XSS breakout from the
@@ -292,10 +386,20 @@ def create_app(
app.state.oidc_redirect_uri = settings.oidc_redirect_uri
app.state.oidc_post_logout_redirect_uri = settings.oidc_post_logout_redirect_uri
app.state.oidc_roles_claim = settings.oidc_roles_claim
app.state.oidc_admin_role = settings.oidc_admin_role
app.state.oidc_member_role = settings.oidc_member_role
app.state.oidc_role_admin = settings.oidc_role_admin
app.state.oidc_role_operator = settings.oidc_role_operator
app.state.oidc_role_member = settings.oidc_role_member
else:
app.state.oidc_enabled = False
app.state.oidc_role_admin = settings.oidc_role_admin
app.state.oidc_role_operator = settings.oidc_role_operator
app.state.oidc_role_member = settings.oidc_role_member
app.state.endpoint_access = _build_endpoint_access(
role_admin=settings.oidc_role_admin,
role_operator=settings.oidc_role_operator,
role_member=settings.oidc_role_member,
)
# Load i18n translations
app.state.web_locale = settings.web_locale or "en"
@@ -304,6 +408,7 @@ def create_app(
# Auto-refresh interval
app.state.auto_refresh_seconds = settings.web_auto_refresh_seconds
app.state.web_debug = settings.web_debug
app.state.channel_labels = _build_channel_labels()
# Store configuration in app state (use args if provided, else settings)
@@ -455,24 +560,22 @@ def create_app(
)
async def api_proxy(request: Request, path: str) -> Response:
"""Proxy API requests to the backend API server."""
# OIDC write gating: block non-admin sessions
if request.app.state.oidc_enabled and request.method in (
"POST",
"PUT",
"DELETE",
"PATCH",
oidc_enabled = getattr(request.app.state, "oidc_enabled", False)
user_roles: frozenset[str] = frozenset()
if oidc_enabled:
roles_claim = getattr(request.app.state, "oidc_roles_claim", "roles")
user_roles = frozenset(get_session_roles(request, roles_claim))
if not check_api_access(
path,
request.method,
oidc_enabled,
user_roles,
request.app.state.endpoint_access,
):
_, is_admin = get_user_roles(
request,
request.app.state.oidc_roles_claim,
request.app.state.oidc_admin_role,
request.app.state.oidc_member_role,
return JSONResponse(
{"detail": "Access denied", "code": "AUTH_REQUIRED"},
status_code=403,
)
if not is_admin:
return JSONResponse(
{"detail": "Admin access required", "code": "AUTH_REQUIRED"},
status_code=403,
)
client: httpx.AsyncClient = request.app.state.http_client
url = f"/api/{path}"
@@ -490,6 +593,15 @@ def create_app(
if "content-type" in request.headers:
headers["content-type"] = request.headers["content-type"]
# Inject authenticated user identity when OIDC is enabled
if oidc_enabled:
user = get_session_user(request)
if user and user.get("sub"):
headers["X-User-Id"] = user["sub"]
roles = get_session_roles(request, roles_claim)
if roles:
headers["X-User-Roles"] = ",".join(roles)
try:
response = await client.request(
method=request.method,
@@ -895,15 +1007,11 @@ def create_app(
user = get_session_user(request)
if not user:
return JSONResponse({"detail": "Not authenticated"}, status_code=401)
is_member, is_admin = get_user_roles(
roles = get_session_roles(
request,
request.app.state.oidc_roles_claim,
request.app.state.oidc_admin_role,
request.app.state.oidc_member_role,
)
return JSONResponse(
{"user": user, "is_member": is_member, "is_admin": is_admin}
)
return JSONResponse({"user": user, "roles": roles})
# --- SPA Catch-All (MUST be last) ---
@app.api_route("/{path:path}", methods=["GET"], tags=["SPA"], response_model=None)
+7 -16
View File
@@ -42,26 +42,17 @@ def get_session_user(request: Request) -> dict[str, Any] | None:
return request.session.get("user")
def get_user_roles(
request: Request, roles_claim: str, admin_role: str, member_role: str
) -> tuple[bool, bool]:
"""Extract roles from session. Returns (is_member, is_admin)."""
def get_session_roles(request: Request, roles_claim: str) -> list[str]:
"""Extract roles from session. Returns list of role name strings."""
user = get_session_user(request)
if not user:
return False, False
return []
roles: Any = user.get(roles_claim, [])
if isinstance(roles, str):
roles = [roles]
is_admin = admin_role in roles
is_member = member_role in roles
logger.info(
"OIDC roles check: roles_claim=%s, raw_roles=%s, is_member=%s, is_admin=%s",
roles_claim,
roles,
is_member,
is_admin,
)
return is_member, is_admin
return [roles]
if isinstance(roles, list):
return roles
return []
def strip_userinfo(userinfo: dict[str, Any], roles_claim: str) -> dict[str, Any]:
+9 -2
View File
@@ -6,7 +6,7 @@
*/
import { Router } from './router.js';
import { getConfig, renderAuthSection } from './components.js';
import { getConfig, hasRole, renderAuthSection } from './components.js';
import { loadLocale, t } from './i18n.js';
// Page modules (lazy-loaded)
@@ -24,6 +24,7 @@ const pages = {
adminIndex: () => import('./pages/admin/index.js'),
adminNodeTags: () => import('./pages/admin/node-tags.js'),
adminMembers: () => import('./pages/admin/members.js'),
profile: () => import('./pages/profile.js'),
};
// Main app container
@@ -88,13 +89,18 @@ if (features.pages !== false) {
}
// Admin routes (only register when OIDC disabled or user is admin)
if (!config.oidc_enabled || config.is_admin) {
if (hasRole('admin')) {
router.addRoute('/admin', pageHandler(pages.adminIndex));
router.addRoute('/admin/', pageHandler(pages.adminIndex));
router.addRoute('/admin/node-tags', pageHandler(pages.adminNodeTags));
router.addRoute('/admin/members', pageHandler(pages.adminMembers));
}
// Profile route (only register when OIDC enabled)
if (config.oidc_enabled) {
router.addRoute('/profile', pageHandler(pages.profile));
}
// 404 handler
router.setNotFound(pageHandler(pages.notFound));
@@ -160,6 +166,7 @@ function updatePageTitle(pathname) {
if (features.advertisements !== false) titles['/advertisements'] = composePageTitle('entities.advertisements');
if (features.map !== false) titles['/map'] = composePageTitle('entities.map');
if (features.members !== false) titles['/members'] = composePageTitle('entities.members');
titles['/profile'] = composePageTitle('links.profile');
if (titles[pathname]) {
document.title = titles[pathname];
@@ -23,6 +23,21 @@ export function getConfig() {
return window.__APP_CONFIG__ || {};
}
/**
* Check if the current session has a specific role.
* Returns true when OIDC is disabled (open access).
* Translates symbolic role names (e.g. "admin") to actual IdP role names
* via the role_names config mapping.
* @param {string} roleName - Symbolic role to check
* @returns {boolean}
*/
export function hasRole(roleName) {
const config = getConfig();
if (!config.oidc_enabled) return true;
const actualRole = (config.role_names || {})[roleName] || roleName;
return (config.roles || []).includes(actualRole);
}
/**
* Build channel label map from app config.
* Keys are numeric channel indexes and values are non-empty labels.
@@ -577,6 +592,18 @@ export function submitOnEnter(e) {
* @param {HTMLElement} container - The #auth-section element
* @param {Object} config - App configuration object
*/
function _svgUser() {
return '<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>';
}
function _svgSettings() {
return '<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>';
}
function _svgLogout() {
return '<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" /></svg>';
}
export function renderAuthSection(container, config) {
if (!container) return;
if (!config.oidc_enabled) {
@@ -597,11 +624,25 @@ export function renderAuthSection(container, config) {
const pictureHtml = user.picture
? `<img src="${user.picture}" alt="${displayName}" class="w-8 h-8 rounded-full" />`
: `<span class="text-sm font-bold">${initials}</span>`;
const roleBadge = config.is_admin
? `<span class="badge badge-primary badge-sm">${t('auth.role_admin')}</span>`
: config.is_member
? `<span class="badge badge-ghost badge-sm">${t('auth.role_member')}</span>`
: '';
const roleBadges = (config.roles || [])
.map(r => {
const key = `auth.role_${r}`;
const label = t(key);
const name = label !== key ? label : r;
return `<span class="badge badge-primary badge-xs">${name}</span>`;
})
.join('');
const adminItem = hasRole('admin')
? `<li><a href="/admin/">${_svgSettings()} ${t('entities.admin')}</a></li>`
: '';
const profileItem = `<li><a href="/profile">${_svgUser()} ${t('links.profile')}</a></li>`;
const debugId = config.debug && user.sub
? `<span class="text-xs opacity-40 font-mono">${user.sub}</span>`
: '';
container.innerHTML = `
<div class="dropdown dropdown-end">
@@ -609,8 +650,17 @@ export function renderAuthSection(container, config) {
${pictureHtml}
</div>
<ul tabindex="0" class="dropdown-content menu menu-sm z-[1] p-2 shadow bg-base-100 rounded-box w-52 mt-3">
<li class="menu-title"><span>${displayName}</span>${roleBadge}</li>
<li><a href="/auth/logout">${t('auth.logout')}</a></li>
<li class="menu-title">
<div class="flex flex-col gap-1">
<span class="font-medium">${displayName}</span>
${debugId}
${roleBadges ? `<div class="flex flex-wrap gap-1">${roleBadges}</div>` : ''}
</div>
</li>
<hr class="my-1 opacity-20">
${adminItem}
${profileItem}
<li><a href="/auth/logout">${_svgLogout()} ${t('auth.logout')}</a></li>
</ul>
</div>
`;
@@ -1,11 +1,11 @@
import { html, litRender, unsafeHTML, getConfig, errorAlert, t } from '../../components.js';
import { html, litRender, unsafeHTML, getConfig, hasRole, errorAlert, t } from '../../components.js';
import { iconLock, iconUsers, iconTag } from '../../icons.js';
export async function render(container, params, router) {
try {
const config = getConfig();
if (config.oidc_enabled ? !config.is_admin : false) {
if (!hasRole('admin')) {
litRender(html`
<div class="flex flex-col items-center justify-center py-20">
${iconLock('h-16 w-16 opacity-30 mb-4')}
@@ -1,7 +1,7 @@
import { apiGet, apiPost, apiPut, apiDelete } from '../../api.js';
import {
html, litRender, nothing,
getConfig, errorAlert, successAlert, t, escapeHtml,
getConfig, hasRole, errorAlert, successAlert, t, escapeHtml,
} from '../../components.js';
import { iconLock } from '../../icons.js';
@@ -9,7 +9,7 @@ export async function render(container, params, router) {
try {
const config = getConfig();
if (!config.is_admin && config.oidc_enabled) {
if (!hasRole('admin')) {
litRender(html`
<div class="flex flex-col items-center justify-center py-20">
${iconLock('h-16 w-16 opacity-30 mb-4')}
@@ -1,7 +1,7 @@
import { apiGet, apiPost, apiPut, apiDelete } from '../../api.js';
import {
html, litRender, nothing, unsafeHTML,
getConfig, typeEmoji, formatDateTimeShort, errorAlert,
getConfig, hasRole, typeEmoji, formatDateTimeShort, errorAlert,
successAlert, truncateKey, t, escapeHtml,
} from '../../components.js';
import { iconTag, iconLock } from '../../icons.js';
@@ -10,7 +10,7 @@ export async function render(container, params, router) {
try {
const config = getConfig();
if (!config.is_admin && config.oidc_enabled) {
if (!hasRole('admin')) {
litRender(html`
<div class="flex flex-col items-center justify-center py-20">
${iconLock('h-16 w-16 opacity-30 mb-4')}
@@ -1,8 +1,8 @@
import { apiGet } from '../api.js';
import { apiGet, apiPost, apiDelete } from '../api.js';
import {
html, litRender, nothing,
getConfig, typeEmoji, formatDateTime,
truncateKey, errorAlert, copyToClipboard, t,
getConfig, hasRole, typeEmoji, formatDateTime,
truncateKey, errorAlert, successAlert, copyToClipboard, t,
} from '../components.js';
import { iconError } from '../icons.js';
@@ -126,12 +126,18 @@ export async function render(container, params, router) {
</div>`
: html`<p class="opacity-70">${t('common.no_entity_defined', { entity: t('entities.tags').toLowerCase() })}</p>`;
const adminTagsHtml = (config.oidc_enabled ? config.is_admin : false)
const adminTagsHtml = hasRole('admin')
? html`<div class="mt-3">
<a href="/admin/node-tags?public_key=${node.public_key}" class="btn btn-sm btn-outline">${tags.length > 0 ? t('common.edit_entity', { entity: t('entities.tags') }) : t('common.add_entity', { entity: t('entities.tags') })}</a>
</div>`
: nothing;
const adoptionHtml = renderAdoptionSection(node, config);
const flashMessage = (params.query && params.query.message) || '';
const flashError = (params.query && params.query.error) || '';
const flashHtml = flashMessage ? successAlert(flashMessage) : flashError ? errorAlert(flashError) : nothing;
litRender(html`
<div class="breadcrumbs text-sm mb-4">
<ul>
@@ -151,6 +157,8 @@ export async function render(container, params, router) {
${heroHtml}
${flashHtml}
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<div>
@@ -182,7 +190,9 @@ ${heroHtml}
${adminTagsHtml}
</div>
</div>
</div>`, container);
</div>
${adoptionHtml}`, container);
// Initialize map if coordinates exist
if (hasCoords && typeof L !== 'undefined') {
@@ -227,6 +237,32 @@ ${heroHtml}
cleanupFns.push(() => clearInterval(qrInterval));
}
// Wire up adoption buttons
const adoptBtn = container.querySelector('.btn-adopt-node');
if (adoptBtn) {
adoptBtn.addEventListener('click', async () => {
try {
await apiPost('/api/v1/adoptions', { public_key: node.public_key });
router.navigate('/nodes/' + node.public_key + '?message=' + encodeURIComponent(t('nodes.adopt_success')), true);
} catch (err) {
router.navigate('/nodes/' + node.public_key + '?error=' + encodeURIComponent(err.message), true);
}
});
}
const releaseBtn = container.querySelector('.btn-release-node');
if (releaseBtn) {
releaseBtn.addEventListener('click', async () => {
if (!confirm(t('nodes.release_confirm'))) return;
try {
await apiDelete('/api/v1/adoptions/' + node.public_key);
router.navigate('/nodes/' + node.public_key + '?message=' + encodeURIComponent(t('nodes.release_success')), true);
} catch (err) {
router.navigate('/nodes/' + node.public_key + '?error=' + encodeURIComponent(err.message), true);
}
});
}
return () => {
cleanupFns.forEach(fn => fn());
};
@@ -239,6 +275,55 @@ ${heroHtml}
}
}
function renderAdoptionSection(node, config) {
if (!config.oidc_enabled || !config.user) return nothing;
const isOperator = hasRole('operator');
const isAdmin = hasRole('admin');
if (!isOperator && !isAdmin) {
if (node.adopted_by) {
const ownerName = node.adopted_by.name || node.adopted_by.user_id;
return html`<div class="card bg-base-100 shadow-xl mt-6">
<div class="card-body">
<h2 class="card-title">${t('nodes.ownership')}</h2>
<p class="text-sm opacity-70">${t('nodes.adopted_by', { name: ownerName })}</p>
</div>
</div>`;
}
return nothing;
}
if (node.adopted_by) {
const ownerName = node.adopted_by.name || node.adopted_by.user_id;
const isOwner = node.adopted_by.user_id === config.user.sub;
const canRelease = isOwner || isAdmin;
const releaseBtnHtml = canRelease
? html`<button class="btn btn-sm btn-outline btn-error btn-release-node">${t('nodes.release')}</button>`
: nothing;
return html`<div class="card bg-base-100 shadow-xl mt-6">
<div class="card-body">
<h2 class="card-title">${t('nodes.ownership')}</h2>
<div class="flex items-center justify-between">
<p class="text-sm opacity-70">${t('nodes.adopted_by', { name: ownerName })}</p>
${releaseBtnHtml}
</div>
</div>
</div>`;
}
return html`<div class="card bg-base-100 shadow-xl mt-6">
<div class="card-body">
<h2 class="card-title">${t('nodes.ownership')}</h2>
<p class="text-sm opacity-70">${t('nodes.not_adopted')}</p>
<div class="mt-2">
<button class="btn btn-sm btn-primary btn-adopt-node">${t('nodes.adopt')}</button>
</div>
</div>
</div>`;
}
function renderNotFound(publicKey) {
return html`
<div class="breadcrumbs text-sm mb-4">
@@ -0,0 +1,110 @@
import { apiGet, apiPut } from '../api.js';
import {
html, litRender, nothing,
getConfig, t, errorAlert, successAlert,
formatRelativeTime, formatDateTime,
} from '../components.js';
function renderAdoptedNode(node) {
const displayName = node.name || node.public_key.slice(0, 12) + '...';
const relTime = formatRelativeTime(node.adopted_at);
const fullTime = formatDateTime(node.adopted_at);
return html`<a href="/nodes/${node.public_key}" class="flex items-center justify-between gap-3 p-3 bg-base-200 rounded-lg hover:bg-base-300 transition-colors">
<div class="flex-1 min-w-0">
<div class="font-medium text-sm truncate">${displayName}</div>
<div class="font-mono text-xs opacity-60 truncate">${node.public_key}</div>
</div>
<time class="text-xs opacity-60 whitespace-nowrap shrink-0" datetime=${node.adopted_at} title=${fullTime} data-relative-time>${relTime}</time>
</a>`;
}
export async function render(container, params, router) {
const config = getConfig();
if (!config.oidc_enabled || !config.user) {
litRender(html`
<div class="flex flex-col items-center justify-center py-20">
<h1 class="text-3xl font-bold mb-2">${t('user_profile.title')}</h1>
<p class="opacity-70 mb-6">${t('user_profile.login_to_view')}</p>
<a href="/auth/login" class="btn btn-primary">${t('auth.login')}</a>
</div>`, container);
return;
}
try {
const userId = config.user.sub;
const profilePath = `/api/v1/user/profile/${encodeURIComponent(userId)}`;
const profile = await apiGet(profilePath);
const flashMessage = (params.query && params.query.message) || '';
const flashError = (params.query && params.query.error) || '';
const flashHtml = flashMessage ? successAlert(flashMessage) : flashError ? errorAlert(flashError) : nothing;
const nodesHtml = profile.nodes && profile.nodes.length > 0
? html`<div class="space-y-2">${profile.nodes.map(n => renderAdoptedNode(n))}</div>`
: html`<p class="text-base-content/60 text-sm py-4">${t('user_profile.no_adopted_nodes')}</p>`;
litRender(html`
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">${t('user_profile.title')}</h1>
</div>
${flashHtml}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">${t('user_profile.your_profile')}</h2>
<form id="profile-form" class="py-4 space-y-4">
<div class="form-control">
<label class="label"><span class="label-text">${t('user_profile.name_label')}</span></label>
<input type="text" name="name" class="input input-bordered"
value=${profile.name || ''}
placeholder=${t('user_profile.name_placeholder')} maxlength="255" />
</div>
<div class="form-control">
<label class="label"><span class="label-text">${t('user_profile.callsign_label')}</span></label>
<input type="text" name="callsign" class="input input-bordered"
value=${profile.callsign || ''}
placeholder=${t('user_profile.callsign_placeholder')} maxlength="20" />
</div>
<button type="submit" class="btn btn-primary btn-sm">${t('user_profile.save_profile')}</button>
</form>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">${t('user_profile.adopted_nodes')}</h2>
${nodesHtml}
</div>
</div>
</div>`, container);
const ac = new AbortController();
const signal = ac.signal;
container.querySelector('#profile-form').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const body = {
name: form.name.value.trim() || null,
callsign: form.callsign.value.trim() || null,
};
try {
await apiPut(profilePath, body);
router.navigate('/profile?message=' + encodeURIComponent(t('user_profile.profile_updated')), true);
} catch (err) {
router.navigate('/profile?error=' + encodeURIComponent(err.message), true);
}
}, { signal });
return () => ac.abort();
} catch (e) {
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
}
}
+22 -1
View File
@@ -148,7 +148,15 @@
"channel": "Channel {{number}}"
},
"nodes": {
"scan_to_add": "Scan to add as contact"
"scan_to_add": "Scan to add as contact",
"ownership": "Ownership",
"adopt": "Adopt",
"release": "Release",
"adopted_by": "Adopted by {{name}}",
"not_adopted": "This node has not been adopted by any operator.",
"adopt_success": "Node adopted successfully",
"release_success": "Node released successfully",
"release_confirm": "Are you sure you want to release this node?"
},
"advertisements": {},
"messages": {
@@ -201,6 +209,19 @@
"role_admin": "admin",
"role_member": "member"
},
"user_profile": {
"title": "Profile",
"your_profile": "Your Profile",
"profile_updated": "Profile updated successfully",
"save_profile": "Save Profile",
"name_label": "Display Name",
"name_placeholder": "Your name or preferred name",
"callsign_label": "Callsign",
"callsign_placeholder": "Amateur radio callsign (e.g., W1ABC)",
"adopted_nodes": "Adopted Nodes",
"no_adopted_nodes": "No adopted nodes",
"login_to_view": "Log in to view your profile"
},
"admin_members": {
"network_members": "Network Members ({{count}})",
"member_id": "Member ID",
+1 -1
View File
@@ -169,7 +169,7 @@
<a href="{{ network_contact_youtube }}" target="_blank" rel="noopener noreferrer" class="link link-hover">{{ t('links.youtube') }}</a>
{% endif %}
</p>
<p class="text-xs opacity-50 mt-2">{% if oidc_enabled %}<a href="/admin/" class="link link-hover">{{ t('entities.admin') }}</a> | {% endif %}{{ t('footer.powered_by') }} <a href="https://github.com/ipnet-mesh/meshcore-hub" target="_blank" rel="noopener noreferrer" class="link link-hover">MeshCore Hub</a> {{ version }}</p>
<p class="text-xs opacity-50 mt-2">{{ t('footer.powered_by') }} <a href="https://github.com/ipnet-mesh/meshcore-hub" target="_blank" rel="noopener noreferrer" class="link link-hover">MeshCore Hub</a> {{ version }}</p>
</aside>
</footer>
+29
View File
@@ -27,6 +27,8 @@ from meshcore_hub.common.models import (
NodeTag,
Telemetry,
TracePath,
UserProfile,
UserProfileNode,
)
@@ -424,3 +426,30 @@ def sample_node_with_member_tag(api_db_session):
api_db_session.commit()
api_db_session.refresh(node)
return node
@pytest.fixture
def sample_user_profile(api_db_session):
"""Create a sample user profile in the database."""
profile = UserProfile(
user_id="oidc-user-123",
name="Test User",
callsign="W1TEST",
)
api_db_session.add(profile)
api_db_session.commit()
api_db_session.refresh(profile)
return profile
@pytest.fixture
def sample_adopted_node(api_db_session, sample_user_profile, sample_node):
"""Create a sample adopted node association."""
association = UserProfileNode(
user_profile_id=sample_user_profile.id,
node_id=sample_node.id,
)
api_db_session.add(association)
api_db_session.commit()
api_db_session.refresh(association)
return association
+251
View File
@@ -0,0 +1,251 @@
"""Tests for node adoption API routes."""
from meshcore_hub.common.models import UserProfile, UserProfileNode
TEST_USER_ID = "oidc-user-123"
OTHER_USER_ID = "oidc-user-456"
OPERATOR_HEADERS = {
"X-User-Id": TEST_USER_ID,
"X-User-Roles": "operator",
}
ADMIN_HEADERS = {
"X-User-Id": TEST_USER_ID,
"X-User-Roles": "admin",
}
OTHER_OPERATOR_HEADERS = {
"X-User-Id": OTHER_USER_ID,
"X-User-Roles": "operator",
}
OTHER_ADMIN_HEADERS = {
"X-User-Id": OTHER_USER_ID,
"X-User-Roles": "admin",
}
MEMBER_ONLY_HEADERS = {
"X-User-Id": TEST_USER_ID,
"X-User-Roles": "member",
}
NO_ROLES_HEADERS = {
"X-User-Id": TEST_USER_ID,
"X-User-Roles": "",
}
class TestAdoptNode:
"""Tests for POST /v1/adoptions endpoint."""
def test_adopt_node_success(self, client_no_auth, sample_node):
"""Test adopting a node."""
response = client_no_auth.post(
"/api/v1/adoptions",
json={"public_key": sample_node.public_key},
headers=OPERATOR_HEADERS,
)
assert response.status_code == 201
data = response.json()
assert data["public_key"] == sample_node.public_key
assert data["name"] == sample_node.name
assert "adopted_at" in data
def test_adopt_node_auto_creates_profile(self, client_no_auth, sample_node):
"""Test adopting a node auto-creates the user profile."""
response = client_no_auth.post(
"/api/v1/adoptions",
json={"public_key": sample_node.public_key},
headers=OPERATOR_HEADERS,
)
assert response.status_code == 201
def test_adopt_node_by_admin(self, client_no_auth, sample_node):
"""Test an admin can adopt a node."""
response = client_no_auth.post(
"/api/v1/adoptions",
json={"public_key": sample_node.public_key},
headers=ADMIN_HEADERS,
)
assert response.status_code == 201
def test_adopt_node_duplicate(self, client_no_auth, sample_adopted_node):
"""Test adopting an already-adopted node by the same user fails."""
response = client_no_auth.post(
"/api/v1/adoptions",
json={"public_key": "abc123def456abc123def456abc123de"},
headers=OPERATOR_HEADERS,
)
assert response.status_code == 409
assert "already adopted" in response.json()["detail"].lower()
def test_adopt_node_already_adopted_by_other(
self, client_no_auth, api_db_session, sample_node
):
"""Test adopting a node adopted by another user fails."""
other_profile = UserProfile(user_id=OTHER_USER_ID, name="Other")
api_db_session.add(other_profile)
api_db_session.commit()
api_db_session.refresh(other_profile)
assoc = UserProfileNode(
user_profile_id=other_profile.id,
node_id=sample_node.id,
)
api_db_session.add(assoc)
api_db_session.commit()
response = client_no_auth.post(
"/api/v1/adoptions",
json={"public_key": sample_node.public_key},
headers=OPERATOR_HEADERS,
)
assert response.status_code == 409
assert "another user" in response.json()["detail"].lower()
def test_adopt_node_not_found(self, client_no_auth):
"""Test adopting a non-existent node fails."""
response = client_no_auth.post(
"/api/v1/adoptions",
json={"public_key": "a" * 64},
headers=OPERATOR_HEADERS,
)
assert response.status_code == 404
def test_adopt_node_requires_operator_or_admin_role(
self, client_no_auth, sample_node
):
"""Test adopting requires operator or admin role."""
response = client_no_auth.post(
"/api/v1/adoptions",
json={"public_key": sample_node.public_key},
headers=MEMBER_ONLY_HEADERS,
)
assert response.status_code == 403
def test_adopt_node_requires_role_header(self, client_no_auth, sample_node):
"""Test adopting requires role in X-User-Roles."""
response = client_no_auth.post(
"/api/v1/adoptions",
json={"public_key": sample_node.public_key},
headers=NO_ROLES_HEADERS,
)
assert response.status_code == 403
def test_adopt_node_rejects_missing_user_id(self, client_no_auth, sample_node):
"""Test adopting without X-User-Id header is rejected."""
response = client_no_auth.post(
"/api/v1/adoptions",
json={"public_key": sample_node.public_key},
)
assert response.status_code == 401
class TestReleaseNode:
"""Tests for DELETE /v1/adoptions/{public_key} endpoint."""
def test_release_own_node_success(
self, client_no_auth, sample_node, sample_adopted_node
):
"""Test operator releasing their own adopted node."""
response = client_no_auth.delete(
f"/api/v1/adoptions/{sample_node.public_key}",
headers=OPERATOR_HEADERS,
)
assert response.status_code == 204
def test_release_node_not_adopted(self, client_no_auth, sample_node):
"""Test releasing a node that is not adopted."""
response = client_no_auth.delete(
f"/api/v1/adoptions/{sample_node.public_key}",
headers=OPERATOR_HEADERS,
)
assert response.status_code == 404
def test_release_node_not_found(self, client_no_auth):
"""Test releasing a non-existent node fails."""
response = client_no_auth.delete(
f"/api/v1/adoptions/{'z' * 64}",
headers=OPERATOR_HEADERS,
)
assert response.status_code == 404
def test_release_node_requires_operator_or_admin(
self, client_no_auth, sample_node, sample_adopted_node
):
"""Test releasing requires operator or admin role."""
response = client_no_auth.delete(
f"/api/v1/adoptions/{sample_node.public_key}",
headers=MEMBER_ONLY_HEADERS,
)
assert response.status_code == 403
def test_operator_cannot_release_others_node(
self, client_no_auth, api_db_session, sample_node, sample_user_profile
):
"""Test operator cannot release a node adopted by another user."""
other_profile = UserProfile(user_id=OTHER_USER_ID, name="Other")
api_db_session.add(other_profile)
api_db_session.commit()
api_db_session.refresh(other_profile)
assoc = UserProfileNode(
user_profile_id=other_profile.id,
node_id=sample_node.id,
)
api_db_session.add(assoc)
api_db_session.commit()
response = client_no_auth.delete(
f"/api/v1/adoptions/{sample_node.public_key}",
headers=OPERATOR_HEADERS,
)
assert response.status_code == 403
def test_admin_can_release_others_node(
self, client_no_auth, api_db_session, sample_node
):
"""Test admin can release a node adopted by another user."""
other_profile = UserProfile(user_id=OTHER_USER_ID, name="Other")
api_db_session.add(other_profile)
api_db_session.commit()
api_db_session.refresh(other_profile)
assoc = UserProfileNode(
user_profile_id=other_profile.id,
node_id=sample_node.id,
)
api_db_session.add(assoc)
api_db_session.commit()
response = client_no_auth.delete(
f"/api/v1/adoptions/{sample_node.public_key}",
headers=ADMIN_HEADERS,
)
assert response.status_code == 204
def test_admin_can_release_own_node(
self, client_no_auth, sample_node, api_db_session
):
"""Test admin can release their own adopted node."""
profile = UserProfile(user_id=TEST_USER_ID, name="Admin User")
api_db_session.add(profile)
api_db_session.commit()
api_db_session.refresh(profile)
assoc = UserProfileNode(
user_profile_id=profile.id,
node_id=sample_node.id,
)
api_db_session.add(assoc)
api_db_session.commit()
response = client_no_auth.delete(
f"/api/v1/adoptions/{sample_node.public_key}",
headers=ADMIN_HEADERS,
)
assert response.status_code == 204
def test_release_node_rejects_missing_user_id(
self, client_no_auth, sample_node, sample_adopted_node
):
"""Test releasing without X-User-Id header is rejected."""
response = client_no_auth.delete(
f"/api/v1/adoptions/{sample_node.public_key}",
)
assert response.status_code == 401
+28
View File
@@ -22,6 +22,21 @@ class TestListNodes:
assert data["items"][0]["public_key"] == sample_node.public_key
assert data["items"][0]["name"] == sample_node.name
assert "tags" in data["items"][0]
assert data["items"][0]["adopted_by"] is None
def test_list_nodes_with_adopted_node(
self, client_no_auth, sample_node, sample_user_profile, sample_adopted_node
):
"""Test listing nodes includes adopted_by info."""
response = client_no_auth.get("/api/v1/nodes")
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 1
adopted_by = data["items"][0]["adopted_by"]
assert adopted_by is not None
assert adopted_by["user_id"] == "oidc-user-123"
assert adopted_by["name"] == "Test User"
assert adopted_by["callsign"] == "W1TEST"
def test_list_nodes_includes_tags(
self, client_no_auth, sample_node, sample_node_tag
@@ -180,6 +195,19 @@ class TestGetNode:
assert data["name"] == sample_node.name
assert "tags" in data
assert data["tags"] == []
assert data["adopted_by"] is None
def test_get_node_with_adoption(
self, client_no_auth, sample_node, sample_user_profile, sample_adopted_node
):
"""Test getting a node shows adopted_by info."""
response = client_no_auth.get(f"/api/v1/nodes/{sample_node.public_key}")
assert response.status_code == 200
data = response.json()
assert data["adopted_by"] is not None
assert data["adopted_by"]["user_id"] == "oidc-user-123"
assert data["adopted_by"]["name"] == "Test User"
assert data["adopted_by"]["callsign"] == "W1TEST"
def test_get_node_with_tags(self, client_no_auth, sample_node, sample_node_tag):
"""Test getting a node includes its tags."""
+154
View File
@@ -0,0 +1,154 @@
"""Tests for user profile API routes."""
TEST_USER_ID = "oidc-user-123"
OTHER_USER_ID = "oidc-user-456"
USER_HEADERS = {"X-User-Id": TEST_USER_ID, "X-User-Roles": "operator"}
OTHER_USER_HEADERS = {"X-User-Id": OTHER_USER_ID, "X-User-Roles": "operator"}
OPERATOR_HEADERS = {
"X-User-Id": TEST_USER_ID,
"X-User-Roles": "operator",
}
MEMBER_ONLY_HEADERS = {
"X-User-Id": TEST_USER_ID,
"X-User-Roles": "member",
}
NO_ROLES_HEADERS = {
"X-User-Id": TEST_USER_ID,
"X-User-Roles": "",
}
class TestGetProfile:
"""Tests for GET /user/profile/{user_id} endpoint."""
def test_get_profile_auto_creates(self, client_no_auth):
"""Test getting a non-existent profile auto-creates it."""
response = client_no_auth.get(
f"/api/v1/user/profile/{TEST_USER_ID}",
headers=USER_HEADERS,
)
assert response.status_code == 200
data = response.json()
assert data["user_id"] == TEST_USER_ID
assert data["name"] is None
assert data["callsign"] is None
assert "id" in data
assert "created_at" in data
assert data["nodes"] == []
def test_get_existing_profile(self, client_no_auth, sample_user_profile):
"""Test getting an existing profile."""
response = client_no_auth.get(
f"/api/v1/user/profile/{sample_user_profile.user_id}",
headers=USER_HEADERS,
)
assert response.status_code == 200
data = response.json()
assert data["user_id"] == sample_user_profile.user_id
assert data["name"] == sample_user_profile.name
assert data["callsign"] == sample_user_profile.callsign
def test_get_profile_with_adopted_nodes(
self, client_no_auth, sample_user_profile, sample_adopted_node
):
"""Test profile includes adopted nodes."""
response = client_no_auth.get(
f"/api/v1/user/profile/{sample_user_profile.user_id}",
headers=USER_HEADERS,
)
assert response.status_code == 200
data = response.json()
assert len(data["nodes"]) == 1
assert data["nodes"][0]["public_key"] == "abc123def456abc123def456abc123de"
assert "adopted_at" in data["nodes"][0]
def test_get_profile_rejects_wrong_user(self, client_no_auth):
"""Test that a user cannot access another user's profile."""
response = client_no_auth.get(
f"/api/v1/user/profile/{TEST_USER_ID}",
headers=OTHER_USER_HEADERS,
)
assert response.status_code == 403
assert "access denied" in response.json()["detail"].lower()
def test_get_profile_rejects_missing_user_id(self, client_no_auth):
"""Test that missing X-User-Id header is rejected."""
response = client_no_auth.get(
f"/api/v1/user/profile/{TEST_USER_ID}",
)
assert response.status_code == 401
def test_get_profile_requires_auth(self, client_with_auth):
"""Test getting profile requires auth when keys configured."""
response = client_with_auth.get(
f"/api/v1/user/profile/{TEST_USER_ID}",
headers=USER_HEADERS,
)
assert response.status_code == 401
response = client_with_auth.get(
f"/api/v1/user/profile/{TEST_USER_ID}",
headers={
**USER_HEADERS,
"Authorization": "Bearer test-read-key",
},
)
assert response.status_code == 200
class TestUpdateProfile:
"""Tests for PUT /user/profile/{user_id} endpoint."""
def test_update_profile_name(self, client_no_auth, sample_user_profile):
"""Test updating profile name."""
response = client_no_auth.put(
f"/api/v1/user/profile/{sample_user_profile.user_id}",
json={"name": "New Name"},
headers=USER_HEADERS,
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "New Name"
assert data["callsign"] == sample_user_profile.callsign
def test_update_profile_callsign(self, client_no_auth, sample_user_profile):
"""Test updating profile callsign."""
response = client_no_auth.put(
f"/api/v1/user/profile/{sample_user_profile.user_id}",
json={"callsign": "G1NEW"},
headers=USER_HEADERS,
)
assert response.status_code == 200
data = response.json()
assert data["callsign"] == "G1NEW"
assert data["name"] == sample_user_profile.name
def test_update_profile_auto_creates(self, client_no_auth):
"""Test updating a non-existent profile auto-creates it."""
response = client_no_auth.put(
f"/api/v1/user/profile/{TEST_USER_ID}",
json={"name": "Auto Created", "callsign": "W1AUTO"},
headers=USER_HEADERS,
)
assert response.status_code == 200
data = response.json()
assert data["user_id"] == TEST_USER_ID
assert data["name"] == "Auto Created"
assert data["callsign"] == "W1AUTO"
def test_update_profile_rejects_wrong_user(self, client_no_auth):
"""Test that a user cannot update another user's profile."""
response = client_no_auth.put(
f"/api/v1/user/profile/{TEST_USER_ID}",
json={"name": "Hacked"},
headers=OTHER_USER_HEADERS,
)
assert response.status_code == 403
def test_update_profile_rejects_missing_user_id(self, client_no_auth):
"""Test that missing X-User-Id header is rejected."""
response = client_no_auth.put(
f"/api/v1/user/profile/{TEST_USER_ID}",
json={"name": "No Auth"},
)
assert response.status_code == 401
+5 -22
View File
@@ -89,7 +89,7 @@ class TestAdminHome:
In the SPA architecture, admin routes serve the same shell HTML.
Admin access control is handled client-side based on
window.__APP_CONFIG__.is_admin when OIDC is enabled.
window.__APP_CONFIG__.roles when OIDC is enabled.
"""
def test_admin_home_returns_spa_shell(self, admin_client):
@@ -98,11 +98,11 @@ class TestAdminHome:
assert response.status_code == 200
assert "window.__APP_CONFIG__" in response.text
def test_admin_home_config_is_admin(self, admin_client):
"""Test admin config shows is_admin: true."""
def test_admin_home_config_has_admin_role(self, admin_client):
"""Test admin config includes admin in roles."""
response = admin_client.get("/admin/")
config = _extract_config(response.text)
assert config["is_admin"] is True
assert "admin" in config["roles"]
assert config["oidc_enabled"] is True
def test_admin_home_disabled_returns_spa_shell(
@@ -112,7 +112,7 @@ class TestAdminHome:
"""Test admin page returns SPA shell even when OIDC disabled.
The SPA catch-all serves the shell for all routes.
Client-side code checks oidc_enabled/is_admin to show/hide admin UI.
Client-side code checks oidc_enabled/roles to show/hide admin UI.
"""
response = admin_client_disabled.get("/admin/")
assert response.status_code == 200
@@ -146,23 +146,6 @@ class TestAdminNodeTags:
assert "window.__APP_CONFIG__" in response.text
class TestAdminFooterLink:
"""Tests for admin link in footer."""
def test_admin_link_visible_when_oidc_enabled(self, admin_client):
"""Test that admin link appears in footer when OIDC is enabled."""
response = admin_client.get("/")
assert response.status_code == 200
assert 'href="/admin/"' in response.text
assert "Admin" in response.text
def test_admin_link_hidden_when_oidc_disabled(self, admin_client_disabled):
"""Test that admin link does not appear in footer when OIDC disabled."""
response = admin_client_disabled.get("/")
assert response.status_code == 200
assert 'href="/admin/"' not in response.text
def _extract_config(text: str) -> dict[str, Any]:
"""Extract __APP_CONFIG__ from SPA HTML."""
start = text.find("window.__APP_CONFIG__ = ") + len("window.__APP_CONFIG__ = ")
+1 -1
View File
@@ -90,7 +90,7 @@ class TestUnhandledExceptions:
def test_404_api_returns_json(self, error_client: TestClient) -> None:
"""Test 404 on /api/ path returns JSON."""
response = error_client.get("/api/v1/nonexistent")
response = error_client.get("/api/v1/nodes/does_not_exist_pk")
assert response.status_code == 404
assert "application/json" in response.headers["content-type"]
data = response.json()
+27 -35
View File
@@ -20,8 +20,7 @@ class TestOIDCSettingsValidation:
config = _extract_config(text)
assert config["oidc_enabled"] is False
assert config["user"] is None
assert config["is_admin"] is False
assert config["is_member"] is False
assert config["roles"] == []
def test_oidc_enabled_config_injection(self, client_with_oidc: TestClient) -> None:
"""Test OIDC config injection when enabled (no session)."""
@@ -30,8 +29,7 @@ class TestOIDCSettingsValidation:
config = _extract_config(response.text)
assert config["oidc_enabled"] is True
assert config["user"] is None
assert config["is_admin"] is False
assert config["is_member"] is False
assert config["roles"] == []
class TestAuthLogin:
@@ -141,8 +139,8 @@ class TestAuthUser:
assert response.status_code == 200
data = response.json()
assert data["user"]["name"] == "Admin User"
assert data["is_admin"] is True
assert data["is_member"] is True
assert "admin" in data["roles"]
assert "member" in data["roles"]
def test_user_member_session(
self, client_with_oidc_member_session: TestClient
@@ -152,8 +150,8 @@ class TestAuthUser:
assert response.status_code == 200
data = response.json()
assert data["user"]["name"] == "Member User"
assert data["is_admin"] is False
assert data["is_member"] is True
assert "admin" not in data["roles"]
assert "member" in data["roles"]
class TestAdminRouteProtection:
@@ -174,7 +172,7 @@ class TestAdminRouteProtection:
assert "window.__APP_CONFIG__" in response.text
config = _extract_config(response.text)
assert config["oidc_enabled"] is True
assert config["is_admin"] is False
assert "admin" not in config["roles"]
def test_admin_session_gets_spa_shell(
self, client_with_oidc_admin_session: TestClient
@@ -184,23 +182,23 @@ class TestAdminRouteProtection:
assert response.status_code == 200
config = _extract_config(response.text)
assert config["oidc_enabled"] is True
assert config["is_admin"] is True
assert "admin" in config["roles"]
class TestAPIProxyWriteGating:
"""Test API proxy write method gating when OIDC enabled."""
def test_get_not_gated(self, client_with_oidc: TestClient) -> None:
"""Test GET requests are not gated."""
"""Test GET requests to open endpoints are not gated."""
response = client_with_oidc.get("/api/v1/nodes")
assert response.status_code != 403
def test_post_blocked_for_non_admin(
def test_post_blocked_for_member(
self, client_with_oidc_member_session: TestClient
) -> None:
"""Test POST blocked for member session."""
response = client_with_oidc_member_session.post(
"/api/v1/node-tags", json={"key": "test", "value": "test"}
"/api/v1/members", json={"name": "test"}
)
assert response.status_code == 403
@@ -209,18 +207,19 @@ class TestAPIProxyWriteGating:
) -> None:
"""Test POST allowed for admin session."""
response = client_with_oidc_admin_session.post(
"/api/v1/node-tags",
json={"key": "test", "value": "test"},
"/api/v1/members",
json={"name": "test"},
)
# Will get 404 from mock since we didn't set up the endpoint,
# but should not get 403
assert response.status_code != 403
def test_write_not_gated_when_oidc_disabled(self, client: TestClient) -> None:
"""Test write methods not gated when OIDC is disabled."""
response = client.post(
"/api/v1/node-tags", json={"key": "test", "value": "test"}
)
def test_write_blocked_when_oidc_disabled(self, client: TestClient) -> None:
"""Test write methods blocked when OIDC is disabled."""
response = client.post("/api/v1/members", json={"name": "test"})
assert response.status_code == 403
def test_read_open_when_oidc_disabled(self, client: TestClient) -> None:
"""Test GET to open endpoints allowed when OIDC is disabled."""
response = client.get("/api/v1/nodes")
assert response.status_code != 403
@@ -233,7 +232,7 @@ class TestBackwardCompatibility:
config = _extract_config(response.text)
assert "admin_enabled" not in config
assert config["oidc_enabled"] is False
assert config["is_admin"] is False
assert config["roles"] == []
def test_admin_routes_serve_spa_shell_when_oidc_disabled(
self, client: TestClient
@@ -243,12 +242,6 @@ class TestBackwardCompatibility:
assert response.status_code == 200
assert "window.__APP_CONFIG__" in response.text
def test_footer_no_admin_link_when_oidc_disabled(self, client: TestClient) -> None:
"""Test footer has no admin link when OIDC disabled."""
response = client.get("/")
assert response.status_code == 200
assert 'href="/admin/"' not in response.text
class TestConfigInjection:
"""Test config injection values for OIDC state."""
@@ -261,8 +254,8 @@ class TestConfigInjection:
config = _extract_config(response.text)
assert config["oidc_enabled"] is True
assert config["user"]["name"] == "Admin User"
assert config["is_admin"] is True
assert config["is_member"] is True
assert "admin" in config["roles"]
assert "member" in config["roles"]
def test_member_session_config(
self, client_with_oidc_member_session: TestClient
@@ -272,8 +265,8 @@ class TestConfigInjection:
config = _extract_config(response.text)
assert config["oidc_enabled"] is True
assert config["user"]["name"] == "Member User"
assert config["is_admin"] is False
assert config["is_member"] is True
assert "admin" not in config["roles"]
assert "member" in config["roles"]
def test_no_session_config(self, client_with_oidc: TestClient) -> None:
"""Test no session injects correct config values."""
@@ -281,8 +274,7 @@ class TestConfigInjection:
config = _extract_config(response.text)
assert config["oidc_enabled"] is True
assert config["user"] is None
assert config["is_admin"] is False
assert config["is_member"] is False
assert config["roles"] == []
class TestStripUserinfo: