Files
c-relay-pg/plans/multi_admin_plan.md
T

202 lines
8.9 KiB
Markdown

# Multi-Admin Support Plan
## Overview
Enable multiple Nostr pubkeys to act as relay administrators. Currently the relay stores a single `admin_pubkey` string in the `config` table and uses `strcmp()` equality checks throughout the codebase. This plan converts that to a comma-separated list of pubkeys stored in the same config key, with a centralized `is_admin_pubkey()` helper that all authorization gates call.
## Design Principles
1. **Minimal schema change** — keep using the existing `config` table with key `admin_pubkey`. The value changes from a single 64-char hex string to a comma-separated list of 64-char hex strings.
2. **Primary admin concept** — the first pubkey in the list is the "primary admin" (the original one). Only the primary admin can add/remove other admins. This prevents privilege escalation.
3. **Centralized check** — introduce one function `is_admin_pubkey(const char* pubkey)` that all 8 authorization gates call instead of inline `strcmp()`.
4. **In-memory cache** — parse the comma-separated list once on startup/config-change into a static array for O(1)-ish lookups without repeated string parsing.
5. **Backward compatible** — a single pubkey with no commas works identically to today.
## Data Model
```
config table:
key = "admin_pubkey"
value = "hex1,hex2,hex3" (comma-separated, no spaces)
data_type = "string"
```
Maximum admins: 10 (compile-time constant `MAX_ADMIN_PUBKEYS`).
## Architecture
```mermaid
flowchart TD
A[Config Table: admin_pubkey = hex1,hex2,hex3] --> B[reload_admin_pubkeys_cache]
B --> C[Static array: g_admin_pubkeys with count]
D[Any authorization check] --> E[is_admin_pubkey - pubkey]
E --> C
F[CLI: -a flag] --> G[first_time_startup_sequence]
G --> A
H[Admin command: add_admin / remove_admin] --> I[modify_admin_list]
I --> A
I --> B
```
## Implementation Steps
### Step 1: Add centralized admin check infrastructure in config.c / config.h
**New constants and types:**
```c
#define MAX_ADMIN_PUBKEYS 10
```
**New functions in config.h:**
```c
int is_admin_pubkey(const char* pubkey); // Returns 1 if pubkey is in admin list
int get_admin_pubkey_count(void); // Returns number of admins
const char* get_primary_admin_pubkey(void); // Returns first admin pubkey
int reload_admin_pubkeys_cache(void); // Parse config value into cache
int add_admin_pubkey(const char* pubkey); // Add pubkey to admin list (primary admin only)
int remove_admin_pubkey(const char* pubkey); // Remove pubkey from admin list (primary admin only)
```
**Implementation in config.c:**
- Static array `g_admin_pubkeys[MAX_ADMIN_PUBKEYS][65]` and `g_admin_pubkey_count`
- `reload_admin_pubkeys_cache()` reads `admin_pubkey` from config table, splits on commas, validates each is 64 hex chars, populates array
- `is_admin_pubkey()` iterates the cached array with `strcmp()`
- `get_primary_admin_pubkey()` returns `g_admin_pubkeys[0]` — used where only the primary admin matters (WoT sync, etc.)
- `add_admin_pubkey()` / `remove_admin_pubkey()` modify the comma-separated string in the config table and call `reload_admin_pubkeys_cache()`
### Step 2: Replace all 8 authorization strcmp gates
Each of these currently does:
```c
const char* admin_pubkey = get_config_value("admin_pubkey");
if (strcmp(sender_pubkey, admin_pubkey) == 0) { /* authorized */ }
```
Replace with:
```c
if (is_admin_pubkey(sender_pubkey)) { /* authorized */ }
```
**Files and locations:**
| File | Function | Line | Change |
|------|----------|------|--------|
| src/config.c | `process_admin_config_event()` | ~1441 | Replace strcmp with `is_admin_pubkey()` |
| src/config.c | `handle_kind_23456_unified()` | ~2727 | Replace strcmp with `is_admin_pubkey()` |
| src/main.c | `verify_admin_event()` | ~1905 | Replace strcmp with `is_admin_pubkey()` |
| src/main.c | Event storage / WoT trigger | ~1174 | Replace strcmp with `is_admin_pubkey()` |
| src/websockets.c | NIP-42 auth bypass | ~2324 | Replace strcmp with `is_admin_pubkey()` |
| src/websockets.c | NIP-17 DM admin check | ~3560 | Replace strcmp with `is_admin_pubkey()` |
| src/dm_admin.c | `process_admin_dm()` | ~503 | Replace strcmp with `is_admin_pubkey()` |
| src/request_validator.c | Event validation bypass | ~307 | Replace strcmp with `is_admin_pubkey()` |
Each replacement also eliminates the `get_config_value("admin_pubkey")` call and its associated `free()`, simplifying the code.
### Step 3: Update startup / initialization pipeline
**config.c `first_time_startup_sequence()`:**
- No change needed — it already stores a single pubkey. The comma-separated format with one entry is identical to the current format.
**config.c `populate_all_config_values_atomic()`:**
- No change needed — inserts the initial admin_pubkey as a single value.
**config.c `add_pubkeys_to_config_table()`:**
- No change needed — stores single pubkey on migration.
**config.c `apply_cli_overrides_atomic()`:**
- No change needed — CLI override replaces the entire admin_pubkey value.
**main.c startup validation:**
- Update the pre-warm validation at ~line 2438 to call `reload_admin_pubkeys_cache()` and validate that at least one admin pubkey exists, rather than checking for exactly 64 chars.
**Call `reload_admin_pubkeys_cache()`** after:
- `populate_all_config_values_atomic()` completes
- `reload_config_from_table()` completes
- Any admin add/remove operation
### Step 4: Add admin management commands to kind 23456 API
Add two new admin commands that can be sent as encrypted kind 23456 events:
```json
["add_admin", "pubkey_hex"]
["remove_admin", "pubkey_hex"]
["list_admins"]
```
**Authorization rule:** Only the primary admin (first in list) can add/remove other admins. This is enforced in the command handler, not in the general `is_admin_pubkey()` check.
**Implementation location:** `handle_system_command_unified()` in config.c, alongside existing commands like `system_status`, `graceful_shutdown`, etc.
### Step 5: Update WoT sync to handle multiple admins
In `wot_sync_from_admin_kind3()`:
- Currently queries kind 3 events from the single admin pubkey
- Change to use `get_primary_admin_pubkey()` — WoT trust anchor should remain the primary admin
- This is a deliberate design choice: secondary admins can manage the relay but the WoT trust graph is rooted in the primary admin's social graph
### Step 6: Update CLI to accept multiple admin pubkeys
**Option A - Comma-separated single flag (recommended):**
```bash
./c_relay_pg -a npub1...,npub2...,npub3...
```
**Changes:**
- `cli_options_t.admin_pubkey_override` becomes `char admin_pubkeys_override[MAX_ADMIN_PUBKEYS * 65]` (larger buffer for comma-separated list)
- CLI parsing in main.c splits on commas, decodes each npub/hex, reassembles as comma-separated hex
- `make_and_restart_relay.sh` `-a` flag documentation updated
### Step 7: Update config validation in api.c
In the config validation table at line ~912:
```c
{"admin_pubkey", "string", 0, 65},
```
Change max length to accommodate multiple pubkeys:
```c
{"admin_pubkey", "string", 0, MAX_ADMIN_PUBKEYS * 65},
```
### Step 8: Update get_admin_pubkey_cached
`get_admin_pubkey_cached()` currently returns the raw config value. Update to return only the primary admin pubkey for backward compatibility with any callers that expect a single 64-char string.
### Step 9: Update test scripts
Update test scripts that hardcode admin keys to work with the multi-admin system:
- `tests/sql_test.sh`
- `tests/white_black_test.sh`
- `tests/17_nip_test.sh`
- `tests/run_all_tests.sh`
- `tests/.test_keys.txt`
Add new test cases for:
- Adding a second admin via kind 23456 command
- Verifying second admin can send admin commands
- Verifying non-admin cannot send admin commands
- Removing a secondary admin
- Verifying primary admin cannot be removed
- Verifying secondary admin cannot add/remove admins
### Step 10: Update documentation
- `AGENTS.md` — update admin API event structure section
- `docs/configuration_guide.md` — document multi-admin setup
- `docs/user_guide.md` — document admin management commands
- `API.md` — document new add_admin/remove_admin/list_admins commands
- `README.md` — mention multi-admin capability
## Migration / Backward Compatibility
- Existing databases with a single `admin_pubkey` value work without any migration — a string with no commas is treated as a single-admin list
- The `is_admin_pubkey()` function handles both formats transparently
- No schema changes needed — the config table already stores strings of arbitrary length
## Security Considerations
1. **Primary admin privilege** — only the first pubkey in the list can add/remove admins, preventing secondary admins from locking out the primary
2. **No self-removal** — the primary admin cannot remove themselves (prevents accidental lockout)
3. **Max admin limit** — capped at 10 to prevent abuse and keep the linear scan fast
4. **Validation** — each pubkey in the list is validated as 64 hex characters before storage