mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 10:46:51 +00:00
feat(api-tokens): add scoped API tokens for CI/CD automation (Team Pro) (#220)
Add long-lived API tokens with three permission scopes (read-only, deploy-only, full-admin) for CI/CD pipelines, scripts, and automation. - Database: api_tokens table with SHA-256 hashed storage - Auth: extend middleware to authenticate Bearer API tokens - Scope enforcement: middleware restricts actions per token scope - API: CRUD endpoints gated behind Team Pro + admin - UI: ApiTokensSection in Settings Hub with create/revoke/copy flows - Docs: new api-tokens.mdx with usage examples and screenshots
This commit is contained in:
@@ -203,6 +203,7 @@ declare global {
|
||||
interface Request {
|
||||
user?: { username: string; role: 'admin' | 'viewer' };
|
||||
nodeId: number;
|
||||
apiTokenScope?: 'read-only' | 'deploy-only' | 'full-admin';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -235,6 +236,32 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction):
|
||||
const jwtSecret = settings.auth_jwt_secret;
|
||||
if (!jwtSecret) throw new Error('No JWT secret');
|
||||
const decoded = jwt.verify(token, jwtSecret) as { username?: string; role?: string; scope?: string };
|
||||
|
||||
// API token path: scope-based programmatic access
|
||||
if (decoded.scope === 'api_token') {
|
||||
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
||||
const apiToken = DatabaseService.getInstance().getApiTokenByHash(tokenHash);
|
||||
if (!apiToken || apiToken.revoked_at) {
|
||||
res.status(401).json({ error: 'API token not found or revoked' });
|
||||
return;
|
||||
}
|
||||
if (apiToken.expires_at && apiToken.expires_at < Date.now()) {
|
||||
res.status(401).json({ error: 'API token has expired' });
|
||||
return;
|
||||
}
|
||||
DatabaseService.getInstance().updateApiTokenLastUsed(apiToken.id);
|
||||
const creator = DatabaseService.getInstance().getUserById(apiToken.user_id);
|
||||
const roleMap: Record<string, 'admin' | 'viewer'> = {
|
||||
'read-only': 'viewer',
|
||||
'deploy-only': 'viewer',
|
||||
'full-admin': 'admin',
|
||||
};
|
||||
req.user = { username: creator?.username || `api-token:${apiToken.name}`, role: roleMap[apiToken.scope] || 'viewer' };
|
||||
req.apiTokenScope = apiToken.scope as 'read-only' | 'deploy-only' | 'full-admin';
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Accept both user sessions and node proxy tokens. Default role to 'admin' for backward compat with pre-RBAC tokens.
|
||||
req.user = { username: decoded.username || 'node-proxy', role: (decoded.role as 'admin' | 'viewer') || 'admin' };
|
||||
next();
|
||||
@@ -647,6 +674,8 @@ const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
|
||||
'POST /fleet/snapshot/restore': 'Restored fleet backup',
|
||||
'PUT /sso/config': 'Updated SSO configuration',
|
||||
'DELETE /sso/config': 'Deleted SSO configuration',
|
||||
'POST /api-tokens': 'Created API token',
|
||||
'DELETE /api-tokens': 'Revoked API token',
|
||||
};
|
||||
|
||||
function getAuditSummary(method: string, apiPath: string): string {
|
||||
@@ -728,6 +757,45 @@ const requireAdmin = (req: Request, res: Response): boolean => {
|
||||
return true;
|
||||
};
|
||||
|
||||
// Scope enforcement for API tokens — restricts which endpoints a token can reach.
|
||||
const DEPLOY_ALLOWED_PATTERNS: RegExp[] = [
|
||||
/^\/api\/stacks\/[^/]+\/up$/,
|
||||
/^\/api\/stacks\/[^/]+\/down$/,
|
||||
/^\/api\/stacks\/[^/]+\/restart$/,
|
||||
/^\/api\/stacks\/[^/]+\/pull$/,
|
||||
/^\/api\/compose\/(up|down|start|stop|restart|pull)$/,
|
||||
];
|
||||
|
||||
const enforceApiTokenScope = (req: Request, res: Response, next: NextFunction): void => {
|
||||
const scope = req.apiTokenScope;
|
||||
if (!scope) { next(); return; } // Not an API token request
|
||||
if (scope === 'full-admin') { next(); return; }
|
||||
|
||||
if (scope === 'read-only') {
|
||||
if (req.method !== 'GET') {
|
||||
res.status(403).json({ error: 'API token scope "read-only" only allows GET requests.', code: 'SCOPE_DENIED' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (scope === 'deploy-only') {
|
||||
if (req.method === 'GET') { next(); return; }
|
||||
const fullPath = `/api${req.path}`;
|
||||
if (req.method === 'POST' && DEPLOY_ALLOWED_PATTERNS.some(p => p.test(fullPath))) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
res.status(403).json({ error: 'API token scope "deploy-only" does not allow this action.', code: 'SCOPE_DENIED' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(403).json({ error: 'Unknown API token scope.', code: 'SCOPE_DENIED' });
|
||||
};
|
||||
|
||||
app.use('/api', enforceApiTokenScope);
|
||||
|
||||
app.get('/api/license', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const info = LicenseService.getInstance().getLicenseInfo();
|
||||
@@ -3096,6 +3164,95 @@ app.get('/api/audit-log', async (req: Request, res: Response): Promise<void> =>
|
||||
}
|
||||
});
|
||||
|
||||
// --- API Token Routes (Team Pro, admin-only, local-only) ---
|
||||
|
||||
app.post('/api/api-tokens', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireTeamPro(req, res)) return;
|
||||
try {
|
||||
const { name, scope } = req.body;
|
||||
if (!name || typeof name !== 'string' || !name.trim()) {
|
||||
res.status(400).json({ error: 'Token name is required.' });
|
||||
return;
|
||||
}
|
||||
const validScopes = ['read-only', 'deploy-only', 'full-admin'];
|
||||
if (!scope || !validScopes.includes(scope)) {
|
||||
res.status(400).json({ error: `Scope must be one of: ${validScopes.join(', ')}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||
const jwtSecret = settings.auth_jwt_secret;
|
||||
if (!jwtSecret) {
|
||||
res.status(500).json({ error: 'No JWT secret configured.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = DatabaseService.getInstance().getUserByUsername(req.user!.username);
|
||||
if (!user) {
|
||||
res.status(500).json({ error: 'User not found.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const rawToken = jwt.sign({ scope: 'api_token', sub: user.username, jti: crypto.randomUUID() }, jwtSecret);
|
||||
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||
|
||||
const id = DatabaseService.getInstance().addApiToken({
|
||||
token_hash: tokenHash,
|
||||
name: name.trim(),
|
||||
scope: scope as 'read-only' | 'deploy-only' | 'full-admin',
|
||||
user_id: user.id,
|
||||
created_at: Date.now(),
|
||||
expires_at: null,
|
||||
});
|
||||
|
||||
res.status(201).json({ id, token: rawToken });
|
||||
} catch (error) {
|
||||
console.error('[ApiTokens] Create error:', error);
|
||||
res.status(500).json({ error: 'Failed to create API token' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/api-tokens', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireTeamPro(req, res)) return;
|
||||
try {
|
||||
const user = DatabaseService.getInstance().getUserByUsername(req.user!.username);
|
||||
if (!user) { res.status(500).json({ error: 'User not found.' }); return; }
|
||||
const tokens = DatabaseService.getInstance().getApiTokensByUser(user.id);
|
||||
// Never expose token hashes to the client
|
||||
const sanitized = tokens.map(({ token_hash: _hash, ...rest }) => rest);
|
||||
res.json(sanitized);
|
||||
} catch (error) {
|
||||
console.error('[ApiTokens] List error:', error);
|
||||
res.status(500).json({ error: 'Failed to list API tokens' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/api-tokens/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireTeamPro(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
if (isNaN(id)) { res.status(400).json({ error: 'Invalid token ID.' }); return; }
|
||||
|
||||
const apiToken = DatabaseService.getInstance().getApiTokenById(id);
|
||||
if (!apiToken) { res.status(404).json({ error: 'API token not found.' }); return; }
|
||||
|
||||
const user = DatabaseService.getInstance().getUserByUsername(req.user!.username);
|
||||
if (!user || apiToken.user_id !== user.id) {
|
||||
res.status(403).json({ error: 'You can only revoke your own tokens.' });
|
||||
return;
|
||||
}
|
||||
|
||||
DatabaseService.getInstance().revokeApiToken(id);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[ApiTokens] Revoke error:', error);
|
||||
res.status(500).json({ error: 'Failed to revoke API token' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- System Maintenance Routes (The System Janitor) ---
|
||||
|
||||
app.get('/api/system/orphans', async (req: Request, res: Response) => {
|
||||
|
||||
@@ -123,6 +123,20 @@ export interface AuditLogEntry {
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export type ApiTokenScope = 'read-only' | 'deploy-only' | 'full-admin';
|
||||
|
||||
export interface ApiToken {
|
||||
id: number;
|
||||
token_hash: string;
|
||||
name: string;
|
||||
scope: ApiTokenScope;
|
||||
user_id: number;
|
||||
created_at: number;
|
||||
last_used_at: number | null;
|
||||
expires_at: number | null;
|
||||
revoked_at: number | null;
|
||||
}
|
||||
|
||||
export class DatabaseService {
|
||||
private static instance: DatabaseService;
|
||||
private db: Database.Database;
|
||||
@@ -294,6 +308,22 @@ export class DatabaseService {
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_username ON audit_log(username);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
scope TEXT NOT NULL DEFAULT 'read-only',
|
||||
user_id INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER,
|
||||
expires_at INTEGER,
|
||||
revoked_at INTEGER,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id);
|
||||
`);
|
||||
|
||||
// Apply migrations safely (ignore if columns already exist)
|
||||
@@ -767,6 +797,10 @@ export class DatabaseService {
|
||||
return this.db.prepare('SELECT * FROM users WHERE username = ?').get(username) as User | undefined;
|
||||
}
|
||||
|
||||
public getUserById(id: number): User | undefined {
|
||||
return this.db.prepare('SELECT * FROM users WHERE id = ?').get(id) as User | undefined;
|
||||
}
|
||||
|
||||
public getUserByProviderIdentity(authProvider: string, providerId: string): User | undefined {
|
||||
return this.db.prepare('SELECT * FROM users WHERE auth_provider = ? AND provider_id = ?').get(authProvider, providerId) as User | undefined;
|
||||
}
|
||||
@@ -947,4 +981,39 @@ export class DatabaseService {
|
||||
const cutoff = Date.now() - (daysToKeep * 24 * 60 * 60 * 1000);
|
||||
this.db.prepare('DELETE FROM audit_log WHERE timestamp < ?').run(cutoff);
|
||||
}
|
||||
|
||||
// --- API Tokens ---
|
||||
|
||||
public addApiToken(token: Omit<ApiToken, 'id' | 'last_used_at' | 'revoked_at'>): number {
|
||||
const result = this.db.prepare(
|
||||
'INSERT INTO api_tokens (token_hash, name, scope, user_id, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(token.token_hash, token.name, token.scope, token.user_id, token.created_at, token.expires_at);
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
public getApiTokensByUser(userId: number): ApiToken[] {
|
||||
return this.db.prepare(
|
||||
'SELECT * FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC'
|
||||
).all(userId) as ApiToken[];
|
||||
}
|
||||
|
||||
public getApiTokenByHash(tokenHash: string): ApiToken | undefined {
|
||||
return this.db.prepare(
|
||||
'SELECT * FROM api_tokens WHERE token_hash = ?'
|
||||
).get(tokenHash) as ApiToken | undefined;
|
||||
}
|
||||
|
||||
public getApiTokenById(id: number): ApiToken | undefined {
|
||||
return this.db.prepare(
|
||||
'SELECT * FROM api_tokens WHERE id = ?'
|
||||
).get(id) as ApiToken | undefined;
|
||||
}
|
||||
|
||||
public revokeApiToken(id: number): void {
|
||||
this.db.prepare('UPDATE api_tokens SET revoked_at = ? WHERE id = ?').run(Date.now(), id);
|
||||
}
|
||||
|
||||
public updateApiTokenLastUsed(id: number): void {
|
||||
this.db.prepare('UPDATE api_tokens SET last_used_at = ? WHERE id = ?').run(Date.now(), id);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user