feat: Add trusted origins management UI at /users
- Create trusted_origins table for DB-backed origin management - Add API routes for CRUD operations on trusted origins - Add tabbed interface on /users page with Users and Trusted Origins tabs - Seeds default trusted origins (cannaiq.co, findadispo.com, findagram.co, etc.) - Fix TypeScript error in WorkersDashboard fingerprint type 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
92
backend/migrations/110_trusted_origins.sql
Normal file
92
backend/migrations/110_trusted_origins.sql
Normal file
@@ -0,0 +1,92 @@
|
||||
-- Migration: 110_trusted_origins.sql
|
||||
-- Description: Trusted origins for API access without token
|
||||
-- Created: 2024-12-14
|
||||
--
|
||||
-- Manages which domains, IPs, and patterns can access the API without a Bearer token.
|
||||
-- Used by auth middleware to grant 'internal' role to trusted requests.
|
||||
|
||||
-- ============================================================
|
||||
-- TRUSTED ORIGINS TABLE
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS trusted_origins (
|
||||
id SERIAL PRIMARY KEY,
|
||||
|
||||
-- Origin identification
|
||||
name VARCHAR(100) NOT NULL, -- Friendly name (e.g., "CannaIQ Production")
|
||||
origin_type VARCHAR(20) NOT NULL, -- 'domain', 'ip', or 'pattern'
|
||||
origin_value VARCHAR(255) NOT NULL, -- The actual value to match
|
||||
|
||||
-- Metadata
|
||||
description TEXT, -- Optional notes
|
||||
active BOOLEAN DEFAULT TRUE,
|
||||
|
||||
-- Tracking
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
|
||||
-- Constraints
|
||||
CONSTRAINT valid_origin_type CHECK (origin_type IN ('domain', 'ip', 'pattern')),
|
||||
UNIQUE(origin_type, origin_value)
|
||||
);
|
||||
|
||||
-- Index for active lookups (used by auth middleware)
|
||||
CREATE INDEX IF NOT EXISTS idx_trusted_origins_active
|
||||
ON trusted_origins(active) WHERE active = TRUE;
|
||||
|
||||
-- Updated at trigger
|
||||
CREATE OR REPLACE FUNCTION update_trusted_origins_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trusted_origins_updated_at ON trusted_origins;
|
||||
CREATE TRIGGER trusted_origins_updated_at
|
||||
BEFORE UPDATE ON trusted_origins
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_trusted_origins_updated_at();
|
||||
|
||||
-- ============================================================
|
||||
-- SEED DEFAULT TRUSTED ORIGINS
|
||||
-- These match the hardcoded fallbacks in middleware.ts
|
||||
-- ============================================================
|
||||
|
||||
-- Production domains
|
||||
INSERT INTO trusted_origins (name, origin_type, origin_value, description) VALUES
|
||||
('CannaIQ Production', 'domain', 'https://cannaiq.co', 'Main CannaIQ dashboard'),
|
||||
('CannaIQ Production (www)', 'domain', 'https://www.cannaiq.co', 'Main CannaIQ dashboard with www'),
|
||||
('FindADispo Production', 'domain', 'https://findadispo.com', 'Consumer dispensary finder'),
|
||||
('FindADispo Production (www)', 'domain', 'https://www.findadispo.com', 'Consumer dispensary finder with www'),
|
||||
('Findagram Production', 'domain', 'https://findagram.co', 'Instagram-style cannabis discovery'),
|
||||
('Findagram Production (www)', 'domain', 'https://www.findagram.co', 'Instagram-style cannabis discovery with www')
|
||||
ON CONFLICT (origin_type, origin_value) DO NOTHING;
|
||||
|
||||
-- Wildcard patterns
|
||||
INSERT INTO trusted_origins (name, origin_type, origin_value, description) VALUES
|
||||
('CannaBrands Subdomains', 'pattern', '^https://.*\\.cannabrands\\.app$', 'All *.cannabrands.app subdomains'),
|
||||
('CannaIQ Subdomains', 'pattern', '^https://.*\\.cannaiq\\.co$', 'All *.cannaiq.co subdomains')
|
||||
ON CONFLICT (origin_type, origin_value) DO NOTHING;
|
||||
|
||||
-- Local development
|
||||
INSERT INTO trusted_origins (name, origin_type, origin_value, description) VALUES
|
||||
('Local API', 'domain', 'http://localhost:3010', 'Local backend API'),
|
||||
('Local Admin', 'domain', 'http://localhost:8080', 'Local admin dashboard'),
|
||||
('Local Vite Dev', 'domain', 'http://localhost:5173', 'Vite dev server')
|
||||
ON CONFLICT (origin_type, origin_value) DO NOTHING;
|
||||
|
||||
-- Trusted IPs (localhost)
|
||||
INSERT INTO trusted_origins (name, origin_type, origin_value, description) VALUES
|
||||
('Localhost IPv4', 'ip', '127.0.0.1', 'Local machine'),
|
||||
('Localhost IPv6', 'ip', '::1', 'Local machine IPv6'),
|
||||
('Localhost IPv6 Mapped', 'ip', '::ffff:127.0.0.1', 'IPv6-mapped IPv4 localhost')
|
||||
ON CONFLICT (origin_type, origin_value) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- COMMENTS
|
||||
-- ============================================================
|
||||
COMMENT ON TABLE trusted_origins IS 'Domains, IPs, and patterns that can access API without token';
|
||||
COMMENT ON COLUMN trusted_origins.origin_type IS 'domain = exact URL match, ip = IP address, pattern = regex pattern';
|
||||
COMMENT ON COLUMN trusted_origins.origin_value IS 'For domain: full URL. For ip: IP address. For pattern: regex string';
|
||||
@@ -88,6 +88,7 @@ import versionRoutes from './routes/version';
|
||||
import deployStatusRoutes from './routes/deploy-status';
|
||||
import publicApiRoutes from './routes/public-api';
|
||||
import usersRoutes from './routes/users';
|
||||
import trustedOriginsRoutes from './routes/trusted-origins';
|
||||
import staleProcessesRoutes from './routes/stale-processes';
|
||||
import orchestratorAdminRoutes from './routes/orchestrator-admin';
|
||||
import proxyAdminRoutes from './routes/proxy-admin';
|
||||
@@ -122,7 +123,6 @@ import workerRegistryRoutes from './routes/worker-registry';
|
||||
// Per TASK_WORKFLOW_2024-12-10.md: Raw payload access API
|
||||
import payloadsRoutes from './routes/payloads';
|
||||
import k8sRoutes from './routes/k8s';
|
||||
import trustedOriginsRoutes from './routes/trusted-origins';
|
||||
|
||||
|
||||
// Mark requests from trusted domains (cannaiq.co, findagram.co, findadispo.com)
|
||||
|
||||
@@ -1,223 +1,185 @@
|
||||
/**
|
||||
* Trusted Origins Admin Routes
|
||||
*
|
||||
* Manage IPs and domains that bypass API key authentication.
|
||||
* Available at /api/admin/trusted-origins
|
||||
*/
|
||||
|
||||
import { Router, Response } from 'express';
|
||||
import { Router } from 'express';
|
||||
import { pool } from '../db/pool';
|
||||
import { AuthRequest, authMiddleware, requireRole, clearTrustedOriginsCache } from '../auth/middleware';
|
||||
import { authMiddleware, requireRole, AuthRequest, clearTrustedOriginsCache } from '../auth/middleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All routes require admin auth
|
||||
// All routes require authentication and admin/superadmin role
|
||||
router.use(authMiddleware);
|
||||
router.use(requireRole('admin', 'superadmin'));
|
||||
|
||||
/**
|
||||
* GET /api/admin/trusted-origins
|
||||
* List all trusted origins
|
||||
*/
|
||||
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||
// Get all trusted origins
|
||||
router.get('/', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
id,
|
||||
origin_type,
|
||||
origin_value,
|
||||
description,
|
||||
active,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM trusted_origins
|
||||
ORDER BY origin_type, origin_value
|
||||
t.*,
|
||||
u.email as created_by_email
|
||||
FROM trusted_origins t
|
||||
LEFT JOIN users u ON t.created_by = u.id
|
||||
ORDER BY t.origin_type, t.name
|
||||
`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
origins: result.rows,
|
||||
counts: {
|
||||
total: result.rows.length,
|
||||
active: result.rows.filter(r => r.active).length,
|
||||
ips: result.rows.filter(r => r.origin_type === 'ip').length,
|
||||
domains: result.rows.filter(r => r.origin_type === 'domain').length,
|
||||
patterns: result.rows.filter(r => r.origin_type === 'pattern').length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[TrustedOrigins] List error:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
res.json({ origins: result.rows });
|
||||
} catch (error) {
|
||||
console.error('Error fetching trusted origins:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch trusted origins' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/trusted-origins
|
||||
* Add a new trusted origin
|
||||
*/
|
||||
router.post('/', async (req: AuthRequest, res: Response) => {
|
||||
// Get single trusted origin
|
||||
router.get('/:id', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { origin_type, origin_value, description } = req.body;
|
||||
const { id } = req.params;
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
t.*,
|
||||
u.email as created_by_email
|
||||
FROM trusted_origins t
|
||||
LEFT JOIN users u ON t.created_by = u.id
|
||||
WHERE t.id = $1
|
||||
`, [id]);
|
||||
|
||||
if (!origin_type || !origin_value) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'origin_type and origin_value are required',
|
||||
});
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Trusted origin not found' });
|
||||
}
|
||||
|
||||
if (!['ip', 'domain', 'pattern'].includes(origin_type)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'origin_type must be: ip, domain, or pattern',
|
||||
});
|
||||
res.json({ origin: result.rows[0] });
|
||||
} catch (error) {
|
||||
console.error('Error fetching trusted origin:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch trusted origin' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create trusted origin
|
||||
router.post('/', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { name, origin_type, origin_value, description, active } = req.body;
|
||||
|
||||
if (!name || !origin_type || !origin_value) {
|
||||
return res.status(400).json({ error: 'Name, origin_type, and origin_value are required' });
|
||||
}
|
||||
|
||||
// Validate pattern if regex
|
||||
const validTypes = ['domain', 'ip', 'pattern'];
|
||||
if (!validTypes.includes(origin_type)) {
|
||||
return res.status(400).json({ error: 'origin_type must be domain, ip, or pattern' });
|
||||
}
|
||||
|
||||
// Validate pattern if provided
|
||||
if (origin_type === 'pattern') {
|
||||
try {
|
||||
new RegExp(origin_value);
|
||||
} catch {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid regex pattern',
|
||||
});
|
||||
return res.status(400).json({ error: 'Invalid regex pattern' });
|
||||
}
|
||||
}
|
||||
|
||||
const result = await pool.query(`
|
||||
INSERT INTO trusted_origins (origin_type, origin_value, description, created_by)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, origin_type, origin_value, description, active, created_at
|
||||
`, [origin_type, origin_value, description || null, req.user?.id || null]);
|
||||
INSERT INTO trusted_origins (name, origin_type, origin_value, description, active, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *
|
||||
`, [name, origin_type, origin_value, description || null, active !== false, req.user?.id || null]);
|
||||
|
||||
// Invalidate cache
|
||||
// Clear cache so middleware picks up new origin
|
||||
clearTrustedOriginsCache();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
origin: result.rows[0],
|
||||
});
|
||||
res.status(201).json({ origin: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
if (error.code === '23505') {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
error: 'This origin already exists',
|
||||
});
|
||||
return res.status(400).json({ error: 'This origin already exists' });
|
||||
}
|
||||
console.error('[TrustedOrigins] Add error:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
console.error('Error creating trusted origin:', error);
|
||||
res.status(500).json({ error: 'Failed to create trusted origin' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/admin/trusted-origins/:id
|
||||
* Update a trusted origin
|
||||
*/
|
||||
router.put('/:id', async (req: AuthRequest, res: Response) => {
|
||||
// Update trusted origin
|
||||
router.put('/:id', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const { origin_type, origin_value, description, active } = req.body;
|
||||
const { id } = req.params;
|
||||
const { name, origin_type, origin_value, description, active } = req.body;
|
||||
|
||||
// Validate pattern if regex
|
||||
// Validate pattern if provided
|
||||
if (origin_type === 'pattern' && origin_value) {
|
||||
try {
|
||||
new RegExp(origin_value);
|
||||
} catch {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid regex pattern',
|
||||
});
|
||||
return res.status(400).json({ error: 'Invalid regex pattern' });
|
||||
}
|
||||
}
|
||||
|
||||
const result = await pool.query(`
|
||||
UPDATE trusted_origins
|
||||
SET
|
||||
origin_type = COALESCE($1, origin_type),
|
||||
origin_value = COALESCE($2, origin_value),
|
||||
description = COALESCE($3, description),
|
||||
active = COALESCE($4, active),
|
||||
updated_at = NOW()
|
||||
WHERE id = $5
|
||||
RETURNING id, origin_type, origin_value, description, active, updated_at
|
||||
`, [origin_type, origin_value, description, active, id]);
|
||||
name = COALESCE($1, name),
|
||||
origin_type = COALESCE($2, origin_type),
|
||||
origin_value = COALESCE($3, origin_value),
|
||||
description = COALESCE($4, description),
|
||||
active = COALESCE($5, active)
|
||||
WHERE id = $6
|
||||
RETURNING *
|
||||
`, [name, origin_type, origin_value, description, active, id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ success: false, error: 'Origin not found' });
|
||||
return res.status(404).json({ error: 'Trusted origin not found' });
|
||||
}
|
||||
|
||||
// Invalidate cache
|
||||
// Clear cache so middleware picks up changes
|
||||
clearTrustedOriginsCache();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
origin: result.rows[0],
|
||||
});
|
||||
res.json({ origin: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
console.error('[TrustedOrigins] Update error:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
if (error.code === '23505') {
|
||||
return res.status(400).json({ error: 'This origin already exists' });
|
||||
}
|
||||
console.error('Error updating trusted origin:', error);
|
||||
res.status(500).json({ error: 'Failed to update trusted origin' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/trusted-origins/:id
|
||||
* Delete a trusted origin
|
||||
*/
|
||||
router.delete('/:id', async (req: AuthRequest, res: Response) => {
|
||||
// Delete trusted origin
|
||||
router.delete('/:id', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await pool.query(`
|
||||
DELETE FROM trusted_origins WHERE id = $1 RETURNING id, origin_value
|
||||
`, [id]);
|
||||
const result = await pool.query('DELETE FROM trusted_origins WHERE id = $1 RETURNING *', [id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ success: false, error: 'Origin not found' });
|
||||
return res.status(404).json({ error: 'Trusted origin not found' });
|
||||
}
|
||||
|
||||
// Invalidate cache
|
||||
// Clear cache so middleware picks up deletion
|
||||
clearTrustedOriginsCache();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
deleted: result.rows[0],
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[TrustedOrigins] Delete error:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
res.json({ success: true, message: 'Trusted origin deleted' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting trusted origin:', error);
|
||||
res.status(500).json({ error: 'Failed to delete trusted origin' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/trusted-origins/:id/toggle
|
||||
* Toggle active status
|
||||
*/
|
||||
router.post('/:id/toggle', async (req: AuthRequest, res: Response) => {
|
||||
// Toggle active status
|
||||
router.patch('/:id/toggle', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await pool.query(`
|
||||
UPDATE trusted_origins
|
||||
SET active = NOT active, updated_at = NOW()
|
||||
SET active = NOT active
|
||||
WHERE id = $1
|
||||
RETURNING id, origin_type, origin_value, active
|
||||
RETURNING *
|
||||
`, [id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ success: false, error: 'Origin not found' });
|
||||
return res.status(404).json({ error: 'Trusted origin not found' });
|
||||
}
|
||||
|
||||
// Invalidate cache
|
||||
// Clear cache so middleware picks up change
|
||||
clearTrustedOriginsCache();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
origin: result.rows[0],
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[TrustedOrigins] Toggle error:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
res.json({ origin: result.rows[0] });
|
||||
} catch (error) {
|
||||
console.error('Error toggling trusted origin:', error);
|
||||
res.status(500).json({ error: 'Failed to toggle trusted origin' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user