feat: add audit and user lifecycle management

This commit is contained in:
2026-08-30 09:31:38 +08:00
parent 0e3b3bd82d
commit 1ee52eead3
16 changed files with 1108 additions and 71 deletions

View File

@@ -10,6 +10,22 @@ permissions:
jobs:
verify:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: configcenter_test
POSTGRES_USER: configcenter
POSTGRES_PASSWORD: configcenter
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U configcenter -d configcenter_test"
--health-interval 5s
--health-timeout 3s
--health-retries 20
env:
TEST_DATABASE_URL: postgres://configcenter:configcenter@127.0.0.1:5432/configcenter_test?sslmode=disable
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -25,6 +41,13 @@ jobs:
with:
python-version: "3.12"
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
cache-dependency-path: web/package-lock.json
- name: Set up protoc
uses: arduino/setup-protoc@v3
with:
@@ -42,3 +65,6 @@ jobs:
- name: Python SDK compile check
run: PYTHONPATH="$PWD/.tools/python:$PWD/sdk/python" python -m compileall -q sdk/python/configcenter
- name: Web build
run: npm --prefix web ci && npm --prefix web run build

View File

@@ -421,6 +421,7 @@ const NAV_ITEMS = [
{ key: 'namespaces', label: '命名空间管理', icon: FolderTree },
{ key: 'environments', label: '环境管理', icon: Globe },
{ key: 'users', label: '用户与权限', icon: Users, adminOnly: true },
{ key: 'audit', label: '审计日志', icon: History, adminOnly: true },
];
function Sidebar({ nav, setNav, identity, onLogout }) {
const items = NAV_ITEMS.filter((item) => !item.adminOnly || identity?.user?.isAdmin);
@@ -463,7 +464,8 @@ const PAGE_META = {
apps: { title: '应用管理', desc: '管理接入配置中心的应用' },
namespaces: { title: '命名空间管理', desc: '管理各应用下的配置命名空间' },
environments: { title: '环境管理', desc: '管理配置生效的环境' },
users: { title: '用户与权限', desc: '创建用户并分配应用级 viewer / app-owner 角色' },
users: { title: '用户与权限', desc: '管理用户生命周期与应用级 viewer / app-owner 角色' },
audit: { title: '审计日志', desc: '按操作者、动作、资源类型和时间范围追溯管理操作' },
};
function TopBar({ nav }) {
const meta = PAGE_META[nav];
@@ -937,7 +939,7 @@ function UserRoleEditor({ user, apps, notify }) {
} catch (err) { notify(err.message, 'danger'); }
}
if (user.isAdmin) return <span className="inline-flex items-center gap-1 text-xs text-indigo-600"><ShieldCheck size={14} /> 全局管理员</span>;
return <div className="flex items-center gap-2"><select value={appId} onChange={(e) => setAppId(e.target.value)} className="px-2 py-1.5 text-xs rounded border border-slate-200">{apps.map((item) => <option key={item.id} value={item.id}>{item.code}</option>)}</select><select value={currentRole} onChange={(e) => change(e.target.value)} disabled={!appId} className="px-2 py-1.5 text-xs rounded border border-slate-200"><option value="">无权限</option><option value="viewer">viewer</option><option value="app-owner">app-owner</option></select></div>;
return <div className="flex items-center gap-2"><select value={appId} onChange={(e) => setAppId(e.target.value)} className="px-2 py-1.5 text-xs rounded border border-slate-200">{apps.map((item) => <option key={item.id} value={item.id}>{item.code}</option>)}</select><select value={currentRole} onChange={(e) => change(e.target.value)} disabled={!appId || user.disabled} className="px-2 py-1.5 text-xs rounded border border-slate-200 disabled:bg-slate-50 disabled:text-slate-400"><option value="">无权限</option><option value="viewer">viewer</option><option value="app-owner">app-owner</option></select></div>;
}
function CreateUserModal({ onClose, onCreated }) {
@@ -951,8 +953,183 @@ function CreateUserModal({ onClose, onCreated }) {
return <div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-4" onClick={onClose}><form onSubmit={submit} onClick={(e) => e.stopPropagation()} className="bg-white rounded-xl shadow-xl w-full max-w-md overflow-hidden"><div className="px-5 py-4 border-b border-slate-100 flex justify-between"><h3 className="text-sm font-semibold">创建用户</h3><button type="button" onClick={onClose}><X size={18} /></button></div><div className="p-5 space-y-3"><Field label="用户名" required><input required value={form.username} onChange={(e) => set('username', e.target.value)} className={inputCls()} /></Field><Field label="显示名称" required><input required value={form.displayName} onChange={(e) => set('displayName', e.target.value)} className={inputCls()} /></Field><Field label="初始密码(至少 12 位)" required><input required minLength={12} type="password" value={form.password} onChange={(e) => set('password', e.target.value)} className={inputCls()} /></Field><label className="flex items-center gap-2 text-sm text-slate-600"><input type="checkbox" checked={form.isAdmin} onChange={(e) => set('isAdmin', e.target.checked)} /> 全局管理员</label>{error && <p className="text-xs text-rose-600">{error}</p>}</div><div className="px-5 py-3 bg-slate-50 flex justify-end gap-2"><button type="button" onClick={onClose} className="px-3 py-1.5 text-sm text-slate-600">取消</button><button className="px-3 py-1.5 text-sm rounded-lg bg-indigo-600 text-white">创建</button></div></form></div>;
}
function UsersPage({ users, apps, notify, onCreate }) {
return <div className="space-y-4"><div className="flex justify-end"><button onClick={onCreate} className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-indigo-600 text-white hover:bg-indigo-700"><Plus size={15} /> 创建用户</button></div><div className="bg-white rounded-xl border border-slate-200 overflow-hidden overflow-x-auto"><table className="w-full text-sm min-w-[720px]"><thead className="bg-slate-50 text-xs text-slate-500"><tr><th className="text-left font-medium px-4 py-2.5">用户名</th><th className="text-left font-medium px-4 py-2.5">显示名称</th><th className="text-left font-medium px-4 py-2.5">状态</th><th className="text-left font-medium px-4 py-2.5">应用角色</th></tr></thead><tbody className="divide-y divide-slate-100">{users.map((user) => <tr key={user.id}><td className="px-4 py-3 font-mono text-xs">{user.username}</td><td className="px-4 py-3">{user.displayName}</td><td className="px-4 py-3"><span className={`text-xs ${user.disabled ? 'text-rose-600' : 'text-emerald-600'}`}>{user.disabled ? '已禁用' : '正常'}</span></td><td className="px-4 py-3"><UserRoleEditor user={user} apps={apps} notify={notify} /></td></tr>)}{users.length === 0 && <tr><td colSpan={4} className="px-4 py-10 text-center text-slate-400">暂无用户</td></tr>}</tbody></table></div></div>;
function PasswordResetModal({ user, onClose, onSubmit }) {
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
async function submit(e) {
e.preventDefault();
setSubmitting(true);
setError('');
try {
await onSubmit(password);
onClose();
} catch (err) {
setError(err.message);
} finally {
setSubmitting(false);
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-4" onClick={onClose}>
<form onSubmit={submit} onClick={(e) => e.stopPropagation()} className="bg-white rounded-xl shadow-xl w-full max-w-md overflow-hidden">
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
<div><h3 className="text-sm font-semibold">重置密码</h3><p className="text-xs text-slate-400 mt-0.5">{user.username} 的现有登录令牌将立即失效</p></div>
<button type="button" onClick={onClose}><X size={18} /></button>
</div>
<div className="p-5 space-y-3">
<Field label="新密码(至少 12 位)" required>
<input autoFocus required minLength={12} type="password" value={password} onChange={(e) => setPassword(e.target.value)} className={inputCls()} autoComplete="new-password" />
</Field>
{error && <p className="text-xs text-rose-600">{error}</p>}
</div>
<div className="px-5 py-3 bg-slate-50 flex justify-end gap-2">
<button type="button" onClick={onClose} className="px-3 py-1.5 text-sm text-slate-600">取消</button>
<button disabled={submitting} className="px-3 py-1.5 text-sm rounded-lg bg-indigo-600 text-white disabled:opacity-50">{submitting ? '提交中…' : '重置密码'}</button>
</div>
</form>
</div>
);
}
function UsersPage({ users, apps, identity, notify, onCreate, onToggleStatus, onResetPassword, onDelete }) {
const currentUserId = String(identity?.user?.userId || '');
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-xs text-slate-400">禁用密码重置和角色变化都会立即使该用户已签发的 JWT 失效</p>
<button onClick={onCreate} className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-indigo-600 text-white hover:bg-indigo-700"><Plus size={15} /> 创建用户</button>
</div>
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden overflow-x-auto">
<table className="w-full text-sm min-w-[980px]">
<thead className="bg-slate-50 text-xs text-slate-500"><tr><th className="text-left font-medium px-4 py-2.5">用户名</th><th className="text-left font-medium px-4 py-2.5">显示名称</th><th className="text-left font-medium px-4 py-2.5">状态</th><th className="text-left font-medium px-4 py-2.5">应用角色</th><th className="text-left font-medium px-4 py-2.5">更新时间</th><th className="text-right font-medium px-4 py-2.5">操作</th></tr></thead>
<tbody className="divide-y divide-slate-100">
{users.map((user) => {
const isSelf = currentUserId && String(user.id) === currentUserId;
return (
<tr key={user.id} className="hover:bg-slate-50/60">
<td className="px-4 py-3 font-mono text-xs">{user.username}{isSelf && <span className="ml-2 text-[10px] px-1.5 py-0.5 rounded bg-indigo-50 text-indigo-600">当前账号</span>}</td>
<td className="px-4 py-3">{user.displayName}</td>
<td className="px-4 py-3"><span className={`text-xs ${user.disabled ? 'text-rose-600' : 'text-emerald-600'}`}>{user.disabled ? '已禁用' : '正常'}</span></td>
<td className="px-4 py-3"><UserRoleEditor user={user} apps={apps} notify={notify} /></td>
<td className="px-4 py-3 text-xs text-slate-400 whitespace-nowrap">{user.updatedAt || user.createdAt || '-'}</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-1.5">
<button disabled={isSelf && !user.disabled} onClick={() => onToggleStatus(user)} className="px-2 py-1.5 rounded-md border border-slate-200 text-xs text-slate-600 hover:bg-slate-50 disabled:opacity-35 disabled:cursor-not-allowed">{user.disabled ? '启用' : '禁用'}</button>
<button disabled={isSelf} onClick={() => onResetPassword(user)} className="px-2 py-1.5 rounded-md border border-slate-200 text-xs text-slate-600 hover:text-indigo-600 hover:bg-indigo-50 disabled:opacity-35 disabled:cursor-not-allowed">重置密码</button>
<button disabled={isSelf} onClick={() => onDelete(user)} className="p-1.5 rounded-md text-slate-400 hover:text-rose-600 hover:bg-rose-50 disabled:opacity-35 disabled:cursor-not-allowed" title="删除用户"><Trash2 size={14} /></button>
</div>
</td>
</tr>
);
})}
{users.length === 0 && <tr><td colSpan={6} className="px-4 py-10 text-center text-slate-400">暂无用户</td></tr>}
</tbody>
</table>
</div>
</div>
);
}
const AUDIT_ACTIONS = ['', 'create', 'update', 'delete', 'publish', 'rollback', 'enable', 'disable', 'reset_password'];
const AUDIT_TARGETS = ['', 'app', 'env', 'namespace', 'config', 'release', 'gray_rule', 'user', 'user_app_role'];
function AuditPage({ notify }) {
const emptyFilters = { actor: '', action: '', targetType: '', from: '', to: '' };
const [filters, setFilters] = useState(emptyFilters);
const [appliedFilters, setAppliedFilters] = useState(emptyFilters);
const [page, setPage] = useState({ items: [], nextCursor: null });
const [currentCursor, setCurrentCursor] = useState('');
const [backStack, setBackStack] = useState([]);
const [loading, setLoading] = useState(true);
function queryFilters(source, cursor) {
const result = { actor: source.actor, action: source.action, targetType: source.targetType, limit: 50 };
if (source.from) result.from = new Date(source.from).toISOString();
if (source.to) result.to = new Date(source.to).toISOString();
if (cursor) result.cursor = cursor;
return result;
}
async function load(cursor, source) {
setLoading(true);
try {
setPage(await api.listAuditLogs(queryFilters(source, cursor)));
setCurrentCursor(cursor || '');
} catch (err) {
notify(err.message, 'danger');
} finally {
setLoading(false);
}
}
useEffect(() => { load('', emptyFilters); }, []); // eslint-disable-line react-hooks/exhaustive-deps
function apply(e) {
e.preventDefault();
const next = { ...filters };
setAppliedFilters(next);
setBackStack([]);
load('', next);
}
function reset() {
const next = { ...emptyFilters };
setFilters(next);
setAppliedFilters(next);
setBackStack([]);
load('', next);
}
function nextPage() {
if (!page.nextCursor) return;
setBackStack((current) => [...current, currentCursor]);
load(page.nextCursor, appliedFilters);
}
function previousPage() {
if (!backStack.length) return;
const target = backStack[backStack.length - 1];
setBackStack((current) => current.slice(0, -1));
load(target, appliedFilters);
}
const set = (key, value) => setFilters((current) => ({ ...current, [key]: value }));
return (
<div className="space-y-4">
<form onSubmit={apply} className="bg-white rounded-xl border border-slate-200 p-4 grid grid-cols-1 md:grid-cols-3 xl:grid-cols-6 gap-3 items-end">
<Field label="操作者"><input value={filters.actor} onChange={(e) => set('actor', e.target.value)} placeholder="如 admin" className={inputCls()} /></Field>
<Field label="动作"><select value={filters.action} onChange={(e) => set('action', e.target.value)} className={inputCls()}>{AUDIT_ACTIONS.map((value) => <option key={value || 'all'} value={value}>{value || '全部动作'}</option>)}</select></Field>
<Field label="资源类型"><select value={filters.targetType} onChange={(e) => set('targetType', e.target.value)} className={inputCls()}>{AUDIT_TARGETS.map((value) => <option key={value || 'all'} value={value}>{value || '全部类型'}</option>)}</select></Field>
<Field label="开始时间"><input type="datetime-local" value={filters.from} onChange={(e) => set('from', e.target.value)} className={inputCls()} /></Field>
<Field label="结束时间"><input type="datetime-local" value={filters.to} onChange={(e) => set('to', e.target.value)} className={inputCls()} /></Field>
<div className="flex gap-2"><button type="button" onClick={reset} className="flex-1 px-3 py-2 text-sm rounded-lg border border-slate-200 text-slate-600 hover:bg-slate-50">重置</button><button className="flex-1 px-3 py-2 text-sm rounded-lg bg-indigo-600 text-white hover:bg-indigo-700">筛选</button></div>
</form>
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden overflow-x-auto">
<table className="w-full text-sm min-w-[980px]">
<thead className="bg-slate-50 text-xs text-slate-500"><tr><th className="text-left font-medium px-4 py-2.5">时间</th><th className="text-left font-medium px-4 py-2.5">操作者</th><th className="text-left font-medium px-4 py-2.5">动作</th><th className="text-left font-medium px-4 py-2.5">资源</th><th className="text-left font-medium px-4 py-2.5">详情</th></tr></thead>
<tbody className="divide-y divide-slate-100">
{!loading && page.items.map((log) => (
<tr key={log.id} className="align-top hover:bg-slate-50/60">
<td className="px-4 py-3 text-xs text-slate-500 whitespace-nowrap">{log.createdAt}</td>
<td className="px-4 py-3 font-mono text-xs text-slate-700">{log.actor}</td>
<td className="px-4 py-3"><span className="px-2 py-0.5 rounded bg-slate-100 text-slate-600 text-xs">{log.action}</span></td>
<td className="px-4 py-3 text-xs"><span className="font-mono text-indigo-600">{log.targetType}</span>{log.targetId && <span className="text-slate-400 ml-1">#{log.targetId}</span>}</td>
<td className="px-4 py-3 max-w-xl"><pre className="text-[11px] leading-4 text-slate-500 whitespace-pre-wrap break-all max-h-24 overflow-auto">{log.detail == null ? '-' : JSON.stringify(log.detail, null, 2)}</pre></td>
</tr>
))}
{loading && <tr><td colSpan={5} className="px-4 py-10 text-center text-slate-400">正在加载审计日志</td></tr>}
{!loading && page.items.length === 0 && <tr><td colSpan={5} className="px-4 py-10 text-center text-slate-400">没有符合条件的审计记录</td></tr>}
</tbody>
</table>
</div>
<div className="flex justify-end gap-2">
<button disabled={!backStack.length || loading} onClick={previousPage} className="px-3 py-1.5 text-sm rounded-lg border border-slate-200 text-slate-600 disabled:opacity-40">上一页</button>
<button disabled={!page.nextCursor || loading} onClick={nextPage} className="px-3 py-1.5 text-sm rounded-lg border border-slate-200 text-slate-600 disabled:opacity-40">下一页</button>
</div>
</div>
);
}
/* =========================================================================
@@ -985,6 +1162,7 @@ export default function ConfigCenter() {
const [historyOpen, setHistoryOpen] = useState(false);
const [grayModal, setGrayModal] = useState(null);
const [createUserOpen, setCreateUserOpen] = useState(false);
const [passwordResetUser, setPasswordResetUser] = useState(null);
const [releaseComment, setReleaseComment] = useState('');
const [loading, setLoading] = useState(true);
const [authRequired, setAuthRequired] = useState(false);
@@ -1194,13 +1372,59 @@ export default function ConfigCenter() {
});
}
async function reloadUsers() {
setUsers(await api.listUsers());
}
async function createUser(input) {
const created = await api.createUser(input);
setUsers((current) => [...current, created]);
await api.createUser(input);
await reloadUsers();
setCreateUserOpen(false);
notify('用户创建成功');
}
function toggleUserStatus(user) {
const disabling = !user.disabled;
setConfirmState({
title: disabling ? '禁用用户' : '启用用户',
message: disabling
? `禁用「${user.username}」后,该用户现有登录令牌会立即失效。`
: `确定重新启用「${user.username}」吗?`,
confirmLabel: disabling ? '确认禁用' : '确认启用',
onConfirm: async () => {
try {
await api.setUserStatus(user.id, disabling);
await reloadUsers();
setConfirmState(null);
notify(disabling ? '用户已禁用' : '用户已启用', disabling ? 'danger' : 'success');
} catch (err) { notify(err.message, 'danger'); }
},
});
}
function deleteUser(user) {
setConfirmState({
title: '删除用户',
message: `删除「${user.username}」会同时清理其应用角色,且无法恢复。`,
confirmLabel: '确认删除',
onConfirm: async () => {
try {
await api.deleteUser(user.id);
await reloadUsers();
setConfirmState(null);
notify('用户已删除', 'danger');
} catch (err) { notify(err.message, 'danger'); }
},
});
}
async function resetUserPassword(password) {
if (!passwordResetUser) return;
await api.resetUserPassword(passwordResetUser.id, password);
await reloadUsers();
notify('密码已重置,该用户现有登录令牌已失效');
}
/* ---------- 弹窗提交统一分发 ---------- */
async function handleModalSubmit(form) {
if (!modal) return;
@@ -1334,8 +1558,13 @@ export default function ConfigCenter() {
/>
)}
{!loading && nav === 'users' && identity?.user?.isAdmin && (
<UsersPage users={users} apps={apps} notify={notify} onCreate={() => setCreateUserOpen(true)} />
<UsersPage
users={users} apps={apps} identity={identity} notify={notify}
onCreate={() => setCreateUserOpen(true)} onToggleStatus={toggleUserStatus}
onResetPassword={setPasswordResetUser} onDelete={deleteUser}
/>
)}
{!loading && nav === 'audit' && identity?.user?.isAdmin && <AuditPage notify={notify} />}
</main>
</div>
@@ -1366,6 +1595,7 @@ export default function ConfigCenter() {
/>
)}
{createUserOpen && <CreateUserModal onClose={() => setCreateUserOpen(false)} onCreated={createUser} />}
{passwordResetUser && <PasswordResetModal user={passwordResetUser} onClose={() => setPasswordResetUser(null)} onSubmit={resetUserPassword} />}
</div>
);
}

View File

@@ -11,8 +11,9 @@
- etcd 完整快照 + `__meta` 同事务写入;
- 运行时配置读取与 SSE/gRPC Watch统一使用 etcd MVCC revision支持断线续传与 compact 后全量恢复;
- 发布历史和“生成新版本”的可追溯回滚;
- 审计日志查询
- 审计日志按操作者、动作、资源类型、时间范围筛选,并支持稳定游标分页
- 可选 JWT 登录、bcrypt 密码、全局管理员与应用级 `viewer` / `app-owner` RBAC
- 用户启用/禁用、密码重置、删除、最后管理员保护,以及基于 `token_version` 的 JWT 即时失效;
- IP/CIDR、实例 ID、稳定百分比三类灰度规则支持优先级覆盖
- Go/Python SDK支持 REST/SSE 与 gRPC transport、自动重连、全量替换和本地文件缓存兜底
- React Web Console含登录、灰度、用户授权页面、Docker Compose 与本地内存开发模式;
@@ -94,10 +95,13 @@ docker compose --profile monitoring up --build
| `POST` | `/v1/publish` | 创建发布版本,返回 `202` |
| `GET` | `/v1/releases?appId=&nsId=&envId=` | 发布历史与下发状态 |
| `POST` | `/v1/rollback` | 回滚并生成新版本 |
| `GET` | `/v1/audit-logs` | 审计日志 |
| `GET` | `/v1/audit-logs?actor=&action=&targetType=&from=&to=&cursor=&limit=` | 审计日志筛选与游标分页 |
| `POST` | `/v1/auth/login` | 用户登录并签发 JWT |
| `GET` | `/v1/me` | 当前用户与应用角色 |
| `GET/POST` | `/v1/users` | 用户列表/创建(管理员) |
| `PUT` | `/v1/users/{id}/status` | 启用/禁用用户(管理员) |
| `POST` | `/v1/users/{id}/reset-password` | 重置用户密码(管理员) |
| `DELETE` | `/v1/users/{id}` | 删除用户(管理员) |
| `GET/PUT/DELETE` | `/v1/users/{id}/roles[/{appId}]` | 应用角色管理(管理员) |
| `GET/POST` | `/v1/gray-rules` | 当前范围灰度规则列表/创建 |
| `PUT/DELETE` | `/v1/gray-rules/{id}` | 灰度规则更新/删除 |
@@ -105,7 +109,7 @@ docker compose --profile monitoring up --build
| `GET` | `/v1/watch?env=&app=&namespace=&ip=&instance=` | SSE 全量同步与更新事件 |
| `GET` | `/metrics` | Prometheus 指标 |
认证关闭时服务以开发管理员身份运行,并可用 `X-User` 记录操作者;认证开启时操作者来自 JWT客户端不能通过请求头伪造。`viewer` 可以读取应用配置,`app-owner` 还可以维护命名空间、配置、发布、回滚和灰度规则,全局管理员可管理应用、环境、审计和用户授权。
认证关闭时服务以开发管理员身份运行,并可用 `X-User` 记录操作者;认证开启时操作者来自 JWT客户端不能通过请求头伪造。`viewer` 可以读取应用配置,`app-owner` 还可以维护命名空间、配置、发布、回滚和灰度规则,全局管理员可管理应用、环境、审计和用户授权。JWT 中携带用户 `token_version`,每次认证都会校验数据库中的当前用户状态和版本;禁用用户、重置密码、修改/删除应用角色都会使旧令牌立即失效,删除用户后旧令牌同样无法继续使用。
正式 gRPC 契约位于 `api/proto/configcenter/v1/config.proto`,提供 `ConfigService.GetConfig/WatchConfig``AdminService.PublishConfig/RollbackConfig`。认证令牌通过 `authorization: Bearer <token>` metadata 传递。`WatchConfig.start_revision` 为包含式游标:首次订阅传 `0`,重连传 `last_seen_revision + 1`;若历史已被 compact服务端发送最新 `FULL_SYNC` 后从快照 revision 的下一版本继续监听。详细一致性约束见 `docs/adr/0001-runtime-revision-watch.md`
@@ -209,6 +213,8 @@ make loadtest
核心端到端测试覆盖CRUD → 发布事务 → outbox worker → 运行时读取 → 灰度覆盖与指标;单元/HTTP 集成测试还覆盖 JWT 过期与篡改、应用级 RBAC、多版本回滚和 SDK 鉴权/灰度参数。
PostgreSQL 用户生命周期与审计集成测试在设置 `TEST_DATABASE_URL` 时执行CI 会启动 PostgreSQL 16 service 自动运行该测试,覆盖 migration、用户禁用/密码重置/角色变化的 token version 轮换、最后管理员保护和审计游标分页。
压测工具默认验证运行时读取的错误率不超过 1%、p95 不超过 200ms可覆盖目标和阈值
```bash

View File

@@ -29,12 +29,12 @@
## P2审计控制台与用户生命周期
- [ ] 为审计日志 API 增加 actor、action、target type、时间范围和游标分页过滤。
- [ ] Web Console 增加审计页面、筛选条件、详情查看和分页。
- [ ] 增加用户启用/禁用、密码重置和删除接口及管理页面。
- [ ] 防止删除或禁用最后一个管理员,并限制用户破坏自己的当前管理会话
- [ ] 明确 JWT 即时失效策略:用户禁用、密码重置或权限收回后,已签发令牌必须在定义的时限内失效,并补齐对应测试
- [ ] 用户状态、密码重置和角色变更全部写入审计日志;补齐 PostgreSQL、内存 Store 和 HTTP 权限测试。
- [x] 为审计日志 API 增加 actor、action、target type、时间范围和基于审计 ID 的游标分页过滤。
- [x] Web Console 增加审计页面、筛选条件、详情查看和上一页/下一页游标分页。
- [x] 增加用户启用/禁用、密码重置和删除接口及管理页面。
- [x] 防止删除或禁用最后一个管理员,并禁止当前管理员通过管理接口禁用、重置密码或删除自身账号
- [x] JWT 即时失效采用 `users.token_version`:登录令牌携带版本,每次认证校验当前用户状态与版本;用户禁用、密码重置和应用角色变更立即递增版本,删除用户后令牌立即失效
- [x] 用户状态、密码重置和角色变更全部写入审计日志;补齐 PostgreSQL 真实集成测试、内存 Store 和 HTTP 权限/令牌失效测试。
完成标准:管理员可完整管理用户生命周期并追溯操作;越权、最后管理员保护和令牌失效场景都有自动化测试。
@@ -59,7 +59,7 @@
1. ~~固定 revision / Watch / Outbox 一致性模型。~~ 已完成。
2. ~~gRPC 代码生成、服务端和 SDK transport。~~ 已完成。
3. 审计控制台与用户生命周期,并明确 JWT 即时失效策略。
3. ~~审计控制台与用户生命周期,并明确 JWT 即时失效策略。~~ 已完成。
4. Redis ADR 和清理/实现。
5. CI/CD、制品签名、SBOM、漏洞扫描和发布规则。
6. 预生产 mTLS、备份恢复、故障演练与生产准入验收。

View File

@@ -228,7 +228,7 @@ func (s *Server) resolveScope(ctx context.Context, env, app, namespace string) (
func (s *Server) authenticateContext(ctx context.Context) (context.Context, error) {
values, _ := metadata.FromIncomingContext(ctx)
principal, err := s.authorizer.AuthenticateToken(bearerToken(values.Get("authorization")))
principal, err := s.authorizer.AuthenticateToken(ctx, bearerToken(values.Get("authorization")))
if err != nil {
return ctx, err
}

View File

@@ -68,6 +68,9 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /v1/me", s.me)
s.mux.HandleFunc("GET /v1/users", s.listUsers)
s.mux.HandleFunc("POST /v1/users", s.createUser)
s.mux.HandleFunc("PUT /v1/users/{id}/status", s.setUserStatus)
s.mux.HandleFunc("POST /v1/users/{id}/reset-password", s.resetUserPassword)
s.mux.HandleFunc("DELETE /v1/users/{id}", s.deleteUser)
s.mux.HandleFunc("GET /v1/users/{id}/roles", s.listUserRoles)
s.mux.HandleFunc("PUT /v1/users/{id}/roles/{appId}", s.setUserRole)
s.mux.HandleFunc("DELETE /v1/users/{id}/roles/{appId}", s.deleteUserRole)
@@ -188,6 +191,69 @@ func (s *Server) createUser(w http.ResponseWriter, r *http.Request) {
s.respond(w, item, err, http.StatusCreated)
}
func (s *Server) setUserStatus(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
userID, ok := pathID(w, r)
if !ok {
return
}
var input struct {
Disabled bool `json:"disabled"`
}
if !decode(w, r, &input) {
return
}
if input.Disabled && isCurrentUser(r.Context(), userID) {
writeError(w, http.StatusConflict, "self_session_protection", "不能禁用当前登录的管理员账号")
return
}
item, err := s.store.SetUserDisabled(r.Context(), userID, input.Disabled, actor(r))
s.respond(w, item, err, http.StatusOK)
}
func (s *Server) resetUserPassword(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
userID, ok := pathID(w, r)
if !ok {
return
}
if isCurrentUser(r.Context(), userID) {
writeError(w, http.StatusConflict, "self_session_protection", "不能通过管理接口重置当前登录账号的密码")
return
}
var input struct {
Password string `json:"password"`
}
if !decode(w, r, &input) {
return
}
err := s.authorizer.ResetUserPassword(r.Context(), userID, input.Password, actor(r))
if err != nil && strings.Contains(err.Error(), "12 characters") {
writeError(w, http.StatusBadRequest, "weak_password", "密码至少需要 12 个字符")
return
}
s.respondEmpty(w, err)
}
func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
userID, ok := pathID(w, r)
if !ok {
return
}
if isCurrentUser(r.Context(), userID) {
writeError(w, http.StatusConflict, "self_session_protection", "不能删除当前登录的管理员账号")
return
}
s.respondEmpty(w, s.store.DeleteUser(r.Context(), userID, actor(r)))
}
func (s *Server) listUserRoles(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
@@ -586,9 +652,43 @@ func (s *Server) listAuditLogs(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
items, err := s.store.ListAuditLogs(r.Context(), limit)
s.respond(w, items, err, http.StatusOK)
values := r.URL.Query()
query := domain.AuditLogQuery{
Actor: strings.TrimSpace(values.Get("actor")),
Action: strings.TrimSpace(values.Get("action")),
TargetType: strings.TrimSpace(values.Get("targetType")),
}
if raw := strings.TrimSpace(values.Get("limit")); raw != "" {
limit, err := strconv.Atoi(raw)
if err != nil || limit <= 0 || limit > 200 {
writeError(w, http.StatusBadRequest, "invalid_argument", "limit 必须是 1 到 200 的整数")
return
}
query.Limit = limit
}
if raw := strings.TrimSpace(values.Get("cursor")); raw != "" {
cursor, err := strconv.ParseInt(raw, 10, 64)
if err != nil || cursor <= 0 {
writeError(w, http.StatusBadRequest, "invalid_argument", "cursor 格式不正确")
return
}
query.BeforeID = cursor
}
var err error
if query.From, err = parseOptionalTime(values.Get("from")); err != nil {
writeError(w, http.StatusBadRequest, "invalid_argument", "from 必须使用 RFC3339 时间格式")
return
}
if query.To, err = parseOptionalTime(values.Get("to")); err != nil {
writeError(w, http.StatusBadRequest, "invalid_argument", "to 必须使用 RFC3339 时间格式")
return
}
if query.From != nil && query.To != nil && query.From.After(*query.To) {
writeError(w, http.StatusBadRequest, "invalid_argument", "from 不能晚于 to")
return
}
page, err := s.store.ListAuditLogs(r.Context(), query)
s.respond(w, page, err, http.StatusOK)
}
func (s *Server) listGrayRules(w http.ResponseWriter, r *http.Request) {
@@ -785,6 +885,24 @@ func (s *Server) refreshGrayWatchers(ctx context.Context, rule domain.GrayRule)
}
}
func parseOptionalTime(value string) (*time.Time, error) {
value = strings.TrimSpace(value)
if value == "" {
return nil, nil
}
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
return nil, err
}
parsed = parsed.UTC()
return &parsed, nil
}
func isCurrentUser(ctx context.Context, userID int64) bool {
principal, ok := authpkg.Principal(ctx)
return ok && principal.UserID > 0 && principal.UserID == userID
}
func grayTarget(r *http.Request) gray.Target {
return gray.Target{IP: strings.TrimSpace(r.URL.Query().Get("ip")), Instance: strings.TrimSpace(r.URL.Query().Get("instance"))}
}
@@ -815,6 +933,8 @@ func (s *Server) writeStoreError(w http.ResponseWriter, err error) {
writeError(w, http.StatusConflict, "no_pending_changes", "没有待发布的配置变更")
case errors.Is(err, store.ErrInvalidRollback):
writeError(w, http.StatusConflict, "invalid_rollback", "不能回滚到当前最新版本")
case errors.Is(err, store.ErrLastAdmin):
writeError(w, http.StatusConflict, "last_admin", "不能禁用或删除最后一个可用管理员")
default:
s.logger.Error("api request failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal", "服务器内部错误")
@@ -826,7 +946,12 @@ func (s *Server) writeAuthError(w http.ResponseWriter, err error) {
writeError(w, http.StatusForbidden, "forbidden", "没有执行该操作的权限")
return
}
writeError(w, http.StatusUnauthorized, "unauthorized", "登录状态无效或已过期")
if errors.Is(err, authpkg.ErrUnauthorized) {
writeError(w, http.StatusUnauthorized, "unauthorized", "登录状态无效或已过期")
return
}
s.logger.Error("authentication backend failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal", "服务器内部错误")
}
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool {

View File

@@ -166,17 +166,17 @@ func TestAuthenticationRBACAndMetrics(t *testing.T) {
"username": "reader", "displayName": "Read Only", "password": "reader-password-123",
}, http.StatusCreated, &viewer)
requestJSON(t, handler, http.MethodPut, fmt.Sprintf("/v1/users/%d/roles/%d", viewer.ID, application.ID), adminLogin.Token, map[string]any{"role": auth.RoleViewer}, http.StatusOK, &domain.UserAppRole{})
var audits []domain.AuditLog
var audits domain.AuditLogPage
requestJSON(t, handler, http.MethodGet, "/v1/audit-logs?limit=20", adminLogin.Token, nil, http.StatusOK, &audits)
foundActor := false
for _, audit := range audits {
for _, audit := range audits.Items {
if audit.Actor == "root" && audit.TargetType == "app" && audit.Action == "create" {
foundActor = true
break
}
}
if !foundActor {
t.Fatalf("JWT actor was not propagated to audit log: %#v", audits)
t.Fatalf("JWT actor was not propagated to audit log: %#v", audits.Items)
}
var viewerLogin struct {
@@ -198,6 +198,93 @@ func TestAuthenticationRBACAndMetrics(t *testing.T) {
}
}
func TestUserLifecycleInvalidatesTokensAndAuditPagination(t *testing.T) {
ctx := context.Background()
repository := memory_store.New(false)
runtimeStore := memoryruntime.New()
defer runtimeStore.Close() //nolint:errcheck
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
authorizer, err := auth.New(repository, true, "0123456789abcdef0123456789abcdef", 90*time.Minute)
if err != nil {
t.Fatal(err)
}
if err := authorizer.Bootstrap(ctx, "root", "a-strong-password", "Root Admin"); err != nil {
t.Fatal(err)
}
handler := httpapi.New(repository, runtimeStore, watch.New(ctx, runtimeStore), authorizer, metrics.New(), nil, logger).Handler()
var rootLogin struct {
Token string `json:"token"`
}
requestJSON(t, handler, http.MethodPost, "/v1/auth/login", "", map[string]any{"username": "root", "password": "a-strong-password"}, http.StatusOK, &rootLogin)
var application domain.Application
requestJSON(t, handler, http.MethodPost, "/v1/applications", rootLogin.Token, map[string]any{"code": "lifecycle-app", "name": "Lifecycle App"}, http.StatusCreated, &application)
var user domain.User
requestJSON(t, handler, http.MethodPost, "/v1/users", rootLogin.Token, map[string]any{
"username": "operator", "displayName": "Operator", "password": "operator-password-123",
}, http.StatusCreated, &user)
requestJSON(t, handler, http.MethodPut, fmt.Sprintf("/v1/users/%d/roles/%d", user.ID, application.ID), rootLogin.Token, map[string]any{"role": auth.RoleViewer}, http.StatusOK, &domain.UserAppRole{})
login := func(password string, want int) string {
t.Helper()
if want != http.StatusOK {
requestJSON(t, handler, http.MethodPost, "/v1/auth/login", "", map[string]any{"username": "operator", "password": password}, want, nil)
return ""
}
var result struct {
Token string `json:"token"`
}
requestJSON(t, handler, http.MethodPost, "/v1/auth/login", "", map[string]any{"username": "operator", "password": password}, want, &result)
return result.Token
}
viewerToken := login("operator-password-123", http.StatusOK)
requestJSON(t, handler, http.MethodPut, fmt.Sprintf("/v1/users/%d/roles/%d", user.ID, application.ID), rootLogin.Token, map[string]any{"role": auth.RoleAppOwner}, http.StatusOK, &domain.UserAppRole{})
requestJSON(t, handler, http.MethodGet, "/v1/applications", viewerToken, nil, http.StatusUnauthorized, nil)
ownerToken := login("operator-password-123", http.StatusOK)
requestJSON(t, handler, http.MethodPost, "/v1/namespaces", ownerToken, map[string]any{"appId": application.ID, "name": "application", "type": "properties"}, http.StatusCreated, &domain.Namespace{})
requestJSON(t, handler, http.MethodPut, fmt.Sprintf("/v1/users/%d/status", user.ID), rootLogin.Token, map[string]any{"disabled": true}, http.StatusOK, &domain.User{})
requestJSON(t, handler, http.MethodGet, "/v1/applications", ownerToken, nil, http.StatusUnauthorized, nil)
login("operator-password-123", http.StatusUnauthorized)
requestJSON(t, handler, http.MethodPut, fmt.Sprintf("/v1/users/%d/status", user.ID), rootLogin.Token, map[string]any{"disabled": false}, http.StatusOK, &domain.User{})
reenabledToken := login("operator-password-123", http.StatusOK)
requestJSON(t, handler, http.MethodPost, fmt.Sprintf("/v1/users/%d/reset-password", user.ID), rootLogin.Token, map[string]any{"password": "operator-new-password-456"}, http.StatusNoContent, nil)
requestJSON(t, handler, http.MethodGet, "/v1/applications", reenabledToken, nil, http.StatusUnauthorized, nil)
login("operator-password-123", http.StatusUnauthorized)
newToken := login("operator-new-password-456", http.StatusOK)
requestJSON(t, handler, http.MethodDelete, fmt.Sprintf("/v1/users/%d", user.ID), rootLogin.Token, nil, http.StatusNoContent, nil)
requestJSON(t, handler, http.MethodGet, "/v1/applications", newToken, nil, http.StatusUnauthorized, nil)
root, err := repository.FindUserByUsername(ctx, "root")
if err != nil {
t.Fatal(err)
}
requestJSON(t, handler, http.MethodPut, fmt.Sprintf("/v1/users/%d/status", root.ID), rootLogin.Token, map[string]any{"disabled": true}, http.StatusConflict, nil)
requestJSON(t, handler, http.MethodPost, fmt.Sprintf("/v1/users/%d/reset-password", root.ID), rootLogin.Token, map[string]any{"password": "another-strong-password"}, http.StatusConflict, nil)
requestJSON(t, handler, http.MethodDelete, fmt.Sprintf("/v1/users/%d", root.ID), rootLogin.Token, nil, http.StatusConflict, nil)
var firstPage domain.AuditLogPage
requestJSON(t, handler, http.MethodGet, "/v1/audit-logs?actor=root&targetType=user&limit=2", rootLogin.Token, nil, http.StatusOK, &firstPage)
if len(firstPage.Items) != 2 || firstPage.NextCursor == nil {
t.Fatalf("expected first audit page with cursor, got %#v", firstPage)
}
var secondPage domain.AuditLogPage
requestJSON(t, handler, http.MethodGet, fmt.Sprintf("/v1/audit-logs?actor=root&targetType=user&limit=2&cursor=%d", *firstPage.NextCursor), rootLogin.Token, nil, http.StatusOK, &secondPage)
if len(secondPage.Items) == 0 || secondPage.Items[0].ID >= firstPage.Items[len(firstPage.Items)-1].ID {
t.Fatalf("audit cursor did not advance: first=%#v second=%#v", firstPage.Items, secondPage.Items)
}
var resetPage domain.AuditLogPage
requestJSON(t, handler, http.MethodGet, "/v1/audit-logs?action=reset_password&targetType=user&limit=10", rootLogin.Token, nil, http.StatusOK, &resetPage)
if len(resetPage.Items) != 1 || resetPage.Items[0].TargetID == nil || *resetPage.Items[0].TargetID != user.ID {
t.Fatalf("audit filters failed: %#v", resetPage.Items)
}
}
func post(t *testing.T, handler http.Handler, path string, input any, wantStatus int, output any) {
t.Helper()
payload, err := json.Marshal(input)

View File

@@ -43,6 +43,7 @@ type tokenClaims struct {
UserID int64 `json:"uid"`
DisplayName string `json:"name"`
Admin bool `json:"admin"`
Version int64 `json:"ver"`
IssuedAt int64 `json:"iat"`
ExpiresAt int64 `json:"exp"`
}
@@ -80,7 +81,7 @@ func (m *Manager) Bootstrap(ctx context.Context, username, password, displayName
func (m *Manager) Login(ctx context.Context, username, password string) (string, domain.Principal, error) {
if !m.enabled {
principal := domain.Principal{Username: "admin", DisplayName: "Development Admin", IsAdmin: true}
token, err := m.issue(principal)
token, err := m.issue(principal, 0)
return token, principal, err
}
credential, err := m.store.FindUserByUsername(ctx, normalizeUsername(username))
@@ -88,7 +89,7 @@ func (m *Manager) Login(ctx context.Context, username, password string) (string,
return "", domain.Principal{}, ErrUnauthorized
}
principal := domain.Principal{UserID: credential.ID, Username: credential.Username, DisplayName: credential.DisplayName, IsAdmin: credential.IsAdmin}
token, err := m.issue(principal)
token, err := m.issue(principal, credential.TokenVersion)
return token, principal, err
}
@@ -104,25 +105,36 @@ func (m *Manager) CreateUser(ctx context.Context, input domain.User, password st
return m.store.CreateUser(ctx, input, string(hash))
}
func (m *Manager) ResetUserPassword(ctx context.Context, userID int64, password, updatedBy string) error {
if len(password) < 12 {
return errors.New("password must contain at least 12 characters")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
return m.store.ResetUserPassword(ctx, userID, string(hash), updatedBy)
}
func (m *Manager) AuthenticateRequest(r *http.Request) (domain.Principal, error) {
header := strings.TrimSpace(r.Header.Get("Authorization"))
if !m.enabled {
return m.AuthenticateToken("")
return m.AuthenticateToken(r.Context(), "")
}
if !strings.HasPrefix(header, "Bearer ") {
return domain.Principal{}, ErrUnauthorized
}
return m.AuthenticateToken(strings.TrimSpace(strings.TrimPrefix(header, "Bearer ")))
return m.AuthenticateToken(r.Context(), strings.TrimSpace(strings.TrimPrefix(header, "Bearer ")))
}
func (m *Manager) AuthenticateToken(token string) (domain.Principal, error) {
func (m *Manager) AuthenticateToken(ctx context.Context, token string) (domain.Principal, error) {
if !m.enabled {
return domain.Principal{Username: "admin", DisplayName: "Development Admin", IsAdmin: true}, nil
}
if strings.TrimSpace(token) == "" {
return domain.Principal{}, ErrUnauthorized
}
return m.parse(strings.TrimSpace(token))
return m.parse(ctx, strings.TrimSpace(token))
}
func WithPrincipal(ctx context.Context, principal domain.Principal) context.Context {
@@ -170,9 +182,9 @@ func (m *Manager) CanAccessApp(ctx context.Context, appID int64, minimum string)
return m.RequireAppRole(ctx, appID, minimum) == nil
}
func (m *Manager) issue(principal domain.Principal) (string, error) {
func (m *Manager) issue(principal domain.Principal, version int64) (string, error) {
now := m.now().UTC()
claims := tokenClaims{Issuer: "configcenter", Subject: principal.Username, UserID: principal.UserID, DisplayName: principal.DisplayName, Admin: principal.IsAdmin, IssuedAt: now.Unix(), ExpiresAt: now.Add(m.ttl).Unix()}
claims := tokenClaims{Issuer: "configcenter", Subject: principal.Username, UserID: principal.UserID, DisplayName: principal.DisplayName, Admin: principal.IsAdmin, Version: version, IssuedAt: now.Unix(), ExpiresAt: now.Add(m.ttl).Unix()}
header, _ := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"})
payload, err := json.Marshal(claims)
if err != nil {
@@ -183,7 +195,7 @@ func (m *Manager) issue(principal domain.Principal) (string, error) {
return unsigned + "." + encode(signature), nil
}
func (m *Manager) parse(token string) (domain.Principal, error) {
func (m *Manager) parse(ctx context.Context, token string) (domain.Principal, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return domain.Principal{}, ErrUnauthorized
@@ -205,7 +217,17 @@ func (m *Manager) parse(token string) (domain.Principal, error) {
if claims.ExpiresAt <= now || claims.IssuedAt > now+60 {
return domain.Principal{}, ErrUnauthorized
}
return domain.Principal{UserID: claims.UserID, Username: claims.Subject, DisplayName: claims.DisplayName, IsAdmin: claims.Admin}, nil
credential, err := m.store.FindUserByID(ctx, claims.UserID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return domain.Principal{}, ErrUnauthorized
}
return domain.Principal{}, err
}
if credential.Disabled || credential.TokenVersion != claims.Version || credential.Username != claims.Subject {
return domain.Principal{}, ErrUnauthorized
}
return domain.Principal{UserID: credential.ID, Username: credential.Username, DisplayName: credential.DisplayName, IsAdmin: credential.IsAdmin}, nil
}
func (m *Manager) sign(input string) []byte {

View File

@@ -111,6 +111,21 @@ type AuditLog struct {
CreatedAt time.Time `json:"createdAt"`
}
type AuditLogQuery struct {
Actor string
Action string
TargetType string
From *time.Time
To *time.Time
BeforeID int64
Limit int
}
type AuditLogPage struct {
Items []AuditLog `json:"items"`
NextCursor *int64 `json:"nextCursor,omitempty"`
}
type ConfigEvent struct {
Type string `json:"type"`
Items map[string]string `json:"items"`
@@ -126,12 +141,14 @@ type User struct {
IsAdmin bool `json:"isAdmin"`
Disabled bool `json:"disabled"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
UpdatedBy string `json:"-"`
}
type UserCredential struct {
User
PasswordHash string `json:"-"`
TokenVersion int64 `json:"-"`
}
type UserAppRole struct {

View File

@@ -502,17 +502,46 @@ func (s *Store) GetRelease(_ context.Context, id int64) (domain.Release, error)
return release, nil
}
func (s *Store) ListAuditLogs(_ context.Context, limit int) ([]domain.AuditLog, error) {
func (s *Store) ListAuditLogs(_ context.Context, query domain.AuditLogQuery) (domain.AuditLogPage, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if limit <= 0 || limit > len(s.audits) {
limit = len(s.audits)
limit := query.Limit
if limit <= 0 || limit > 200 {
limit = 50
}
result := make([]domain.AuditLog, 0, limit)
for i := len(s.audits) - 1; i >= len(s.audits)-limit; i-- {
result = append(result, s.audits[i])
matches := make([]domain.AuditLog, 0, limit+1)
for i := len(s.audits) - 1; i >= 0; i-- {
item := s.audits[i]
if query.BeforeID > 0 && item.ID >= query.BeforeID {
continue
}
if query.Actor != "" && !strings.EqualFold(item.Actor, query.Actor) {
continue
}
if query.Action != "" && item.Action != query.Action {
continue
}
if query.TargetType != "" && item.TargetType != query.TargetType {
continue
}
if query.From != nil && item.CreatedAt.Before(*query.From) {
continue
}
if query.To != nil && item.CreatedAt.After(*query.To) {
continue
}
matches = append(matches, item)
if len(matches) == limit+1 {
break
}
}
return result, nil
page := domain.AuditLogPage{Items: matches}
if len(matches) > limit {
page.Items = matches[:limit]
cursor := page.Items[len(page.Items)-1].ID
page.NextCursor = &cursor
}
return page, nil
}
func (s *Store) EnsureBootstrapAdmin(_ context.Context, username, passwordHash, displayName string) error {
@@ -525,7 +554,7 @@ func (s *Store) EnsureBootstrapAdmin(_ context.Context, username, passwordHash,
}
now := time.Now().UTC()
id := s.id()
s.users[id] = domain.UserCredential{User: domain.User{ID: id, Username: username, DisplayName: displayName, IsAdmin: true, CreatedAt: now}, PasswordHash: passwordHash}
s.users[id] = domain.UserCredential{User: domain.User{ID: id, Username: username, DisplayName: displayName, IsAdmin: true, CreatedAt: now, UpdatedAt: now}, PasswordHash: passwordHash, TokenVersion: 1}
s.audit(username, "create", "user", id, map[string]any{"bootstrap": true, "isAdmin": true})
return nil
}
@@ -541,6 +570,16 @@ func (s *Store) FindUserByUsername(_ context.Context, username string) (domain.U
return domain.UserCredential{}, storepkg.ErrNotFound
}
func (s *Store) FindUserByID(_ context.Context, id int64) (domain.UserCredential, error) {
s.mu.RLock()
defer s.mu.RUnlock()
credential, ok := s.users[id]
if !ok {
return domain.UserCredential{}, storepkg.ErrNotFound
}
return credential, nil
}
func (s *Store) ListUsers(context.Context) ([]domain.User, error) {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -560,12 +599,73 @@ func (s *Store) CreateUser(_ context.Context, input domain.User, passwordHash st
return domain.User{}, storepkg.ErrConflict
}
}
input.ID, input.CreatedAt = s.id(), time.Now().UTC()
s.users[input.ID] = domain.UserCredential{User: input, PasswordHash: passwordHash}
now := time.Now().UTC()
input.ID, input.CreatedAt, input.UpdatedAt = s.id(), now, now
s.users[input.ID] = domain.UserCredential{User: input, PasswordHash: passwordHash, TokenVersion: 1}
s.audit(defaultActor(input.UpdatedBy), "create", "user", input.ID, input)
return input, nil
}
func (s *Store) SetUserDisabled(_ context.Context, userID int64, disabled bool, updatedBy string) (domain.User, error) {
s.mu.Lock()
defer s.mu.Unlock()
credential, ok := s.users[userID]
if !ok {
return domain.User{}, storepkg.ErrNotFound
}
if credential.Disabled == disabled {
return credential.User, nil
}
if disabled && credential.IsAdmin && s.activeAdminCountLocked() <= 1 {
return domain.User{}, storepkg.ErrLastAdmin
}
credential.Disabled = disabled
credential.UpdatedAt = time.Now().UTC()
credential.TokenVersion++
s.users[userID] = credential
action := "enable"
if disabled {
action = "disable"
}
s.audit(defaultActor(updatedBy), action, "user", userID, map[string]any{"username": credential.Username, "disabled": disabled})
return credential.User, nil
}
func (s *Store) ResetUserPassword(_ context.Context, userID int64, passwordHash, updatedBy string) error {
s.mu.Lock()
defer s.mu.Unlock()
credential, ok := s.users[userID]
if !ok {
return storepkg.ErrNotFound
}
credential.PasswordHash = passwordHash
credential.TokenVersion++
credential.UpdatedAt = time.Now().UTC()
s.users[userID] = credential
s.audit(defaultActor(updatedBy), "reset_password", "user", userID, map[string]any{"username": credential.Username})
return nil
}
func (s *Store) DeleteUser(_ context.Context, userID int64, updatedBy string) error {
s.mu.Lock()
defer s.mu.Unlock()
credential, ok := s.users[userID]
if !ok {
return storepkg.ErrNotFound
}
if credential.IsAdmin && !credential.Disabled && s.activeAdminCountLocked() <= 1 {
return storepkg.ErrLastAdmin
}
delete(s.users, userID)
for key := range s.userRoles {
if key[0] == userID {
delete(s.userRoles, key)
}
}
s.audit(defaultActor(updatedBy), "delete", "user", userID, map[string]any{"username": credential.Username, "isAdmin": credential.IsAdmin})
return nil
}
func (s *Store) SetUserAppRole(_ context.Context, input domain.UserAppRole) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -579,6 +679,10 @@ func (s *Store) SetUserAppRole(_ context.Context, input domain.UserAppRole) erro
return errors.New("invalid role")
}
s.userRoles[[2]int64{input.UserID, input.AppID}] = input.Role
credential := s.users[input.UserID]
credential.TokenVersion++
credential.UpdatedAt = time.Now().UTC()
s.users[input.UserID] = credential
s.audit(defaultActor(input.UpdatedBy), "update", "user_app_role", input.UserID, input)
return nil
}
@@ -591,6 +695,10 @@ func (s *Store) DeleteUserAppRole(_ context.Context, userID, appID int64, update
return storepkg.ErrNotFound
}
delete(s.userRoles, key)
credential := s.users[userID]
credential.TokenVersion++
credential.UpdatedAt = time.Now().UTC()
s.users[userID] = credential
s.audit(defaultActor(updatedBy), "delete", "user_app_role", userID, map[string]int64{"appId": appID})
return nil
}
@@ -621,6 +729,16 @@ func (s *Store) ListUserAppRoles(_ context.Context, userID int64) ([]domain.User
return result, nil
}
func (s *Store) activeAdminCountLocked() int {
count := 0
for _, credential := range s.users {
if credential.IsAdmin && !credential.Disabled {
count++
}
}
return count
}
func (s *Store) ResolveScope(_ context.Context, envCode, appCode, namespaceName string) (int64, int64, int64, error) {
s.mu.RLock()
defer s.mu.RUnlock()

View File

@@ -78,3 +78,76 @@ func TestRollbackCreatesANewRelease(t *testing.T) {
t.Fatalf("rollback must retain history, got %d releases", len(releases))
}
}
func TestUserLifecycleProtectsLastAdminAndInvalidatesVersion(t *testing.T) {
repository := New(false)
ctx := context.Background()
if err := repository.EnsureBootstrapAdmin(ctx, "root", "hash", "Root"); err != nil {
t.Fatal(err)
}
root, err := repository.FindUserByUsername(ctx, "root")
if err != nil {
t.Fatal(err)
}
if root.TokenVersion != 1 {
t.Fatalf("unexpected initial token version: %d", root.TokenVersion)
}
if _, err := repository.SetUserDisabled(ctx, root.ID, true, "root"); !errors.Is(err, storepkg.ErrLastAdmin) {
t.Fatalf("last admin disable must be rejected: %v", err)
}
if err := repository.DeleteUser(ctx, root.ID, "root"); !errors.Is(err, storepkg.ErrLastAdmin) {
t.Fatalf("last admin delete must be rejected: %v", err)
}
admin, err := repository.CreateUser(ctx, domain.User{Username: "backup", DisplayName: "Backup", IsAdmin: true, UpdatedBy: "root"}, "hash")
if err != nil {
t.Fatal(err)
}
if _, err := repository.SetUserDisabled(ctx, root.ID, true, "backup"); err != nil {
t.Fatal(err)
}
root, err = repository.FindUserByID(ctx, root.ID)
if err != nil {
t.Fatal(err)
}
if !root.Disabled || root.TokenVersion != 2 {
t.Fatalf("disable did not invalidate token version: %#v", root)
}
if err := repository.ResetUserPassword(ctx, admin.ID, "new-hash", "backup"); err != nil {
t.Fatal(err)
}
adminCredential, err := repository.FindUserByID(ctx, admin.ID)
if err != nil {
t.Fatal(err)
}
if adminCredential.TokenVersion != 2 || adminCredential.PasswordHash != "new-hash" {
t.Fatalf("password reset did not rotate token version: %#v", adminCredential)
}
}
func TestAuditLogFiltersAndCursor(t *testing.T) {
repository := New(false)
ctx := context.Background()
for _, code := range []string{"a", "b", "c"} {
if _, err := repository.CreateApplication(ctx, domain.Application{Code: code, Name: code, UpdatedBy: "alice"}); err != nil {
t.Fatal(err)
}
}
if _, err := repository.CreateEnvironment(ctx, domain.Environment{Code: "DEV", Name: "Dev", UpdatedBy: "bob"}); err != nil {
t.Fatal(err)
}
page, err := repository.ListAuditLogs(ctx, domain.AuditLogQuery{Actor: "alice", Action: "create", TargetType: "app", Limit: 2})
if err != nil {
t.Fatal(err)
}
if len(page.Items) != 2 || page.NextCursor == nil {
t.Fatalf("unexpected first page: %#v", page)
}
next, err := repository.ListAuditLogs(ctx, domain.AuditLogQuery{Actor: "alice", Action: "create", TargetType: "app", Limit: 2, BeforeID: *page.NextCursor})
if err != nil {
t.Fatal(err)
}
if len(next.Items) != 1 || next.Items[0].ID >= page.Items[1].ID {
t.Fatalf("cursor did not advance: first=%#v next=%#v", page.Items, next.Items)
}
}

View File

@@ -0,0 +1,6 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS token_version BIGINT NOT NULL DEFAULT 1;
ALTER TABLE users ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now();
CREATE INDEX IF NOT EXISTS audit_logs_actor_id_idx ON audit_logs(actor, id DESC);
CREATE INDEX IF NOT EXISTS audit_logs_action_id_idx ON audit_logs(action, id DESC);
CREATE INDEX IF NOT EXISTS audit_logs_target_type_id_idx ON audit_logs(target_type, id DESC);

View File

@@ -20,7 +20,10 @@ import (
//go:embed migrations/*.sql
var migrations embed.FS
const migrationLockID int64 = 0x436F6E666967 // "Config"
const (
migrationLockID int64 = 0x436F6E666967 // "Config"
adminLifecycleLockID int64 = 0x41646D696E // "Admin"
)
type Store struct {
pool *pgxpool.Pool
@@ -515,24 +518,61 @@ func (s *Store) GetRelease(ctx context.Context, id int64) (domain.Release, error
return item, classify(err)
}
func (s *Store) ListAuditLogs(ctx context.Context, limit int) ([]domain.AuditLog, error) {
if limit <= 0 || limit > 500 {
limit = 100
func (s *Store) ListAuditLogs(ctx context.Context, query domain.AuditLogQuery) (domain.AuditLogPage, error) {
limit := query.Limit
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.pool.Query(ctx, `SELECT id, actor, action, target_type, target_id, detail, created_at FROM audit_logs ORDER BY created_at DESC LIMIT $1`, limit)
conditions := []string{"1=1"}
args := make([]any, 0, 7)
add := func(condition string, value any) {
args = append(args, value)
conditions = append(conditions, fmt.Sprintf(condition, len(args)))
}
if query.Actor != "" {
add("lower(actor)=lower($%d)", query.Actor)
}
if query.Action != "" {
add("action=$%d", query.Action)
}
if query.TargetType != "" {
add("target_type=$%d", query.TargetType)
}
if query.From != nil {
add("created_at >= $%d", *query.From)
}
if query.To != nil {
add("created_at <= $%d", *query.To)
}
if query.BeforeID > 0 {
add("id < $%d", query.BeforeID)
}
args = append(args, limit+1)
statement := fmt.Sprintf(`SELECT id, actor, action, target_type, target_id, detail, created_at
FROM audit_logs WHERE %s ORDER BY id DESC LIMIT $%d`, strings.Join(conditions, " AND "), len(args))
rows, err := s.pool.Query(ctx, statement, args...)
if err != nil {
return nil, err
return domain.AuditLogPage{}, err
}
defer rows.Close()
result := make([]domain.AuditLog, 0)
items := make([]domain.AuditLog, 0, limit+1)
for rows.Next() {
var item domain.AuditLog
if err := rows.Scan(&item.ID, &item.Actor, &item.Action, &item.TargetType, &item.TargetID, &item.Detail, &item.CreatedAt); err != nil {
return nil, err
return domain.AuditLogPage{}, err
}
result = append(result, item)
items = append(items, item)
}
return result, rows.Err()
if err := rows.Err(); err != nil {
return domain.AuditLogPage{}, err
}
page := domain.AuditLogPage{Items: items}
if len(items) > limit {
page.Items = items[:limit]
cursor := page.Items[len(page.Items)-1].ID
page.NextCursor = &cursor
}
return page, nil
}
func (s *Store) EnsureBootstrapAdmin(ctx context.Context, username, passwordHash, displayName string) error {
@@ -545,14 +585,23 @@ func (s *Store) EnsureBootstrapAdmin(ctx context.Context, username, passwordHash
func (s *Store) FindUserByUsername(ctx context.Context, username string) (domain.UserCredential, error) {
var item domain.UserCredential
err := s.pool.QueryRow(ctx, `
SELECT id, username, password_hash, display_name, is_admin, disabled, created_at
SELECT id, username, password_hash, display_name, is_admin, disabled, created_at, updated_at, token_version
FROM users WHERE lower(username)=lower($1)`, username).
Scan(&item.ID, &item.Username, &item.PasswordHash, &item.DisplayName, &item.IsAdmin, &item.Disabled, &item.CreatedAt)
Scan(&item.ID, &item.Username, &item.PasswordHash, &item.DisplayName, &item.IsAdmin, &item.Disabled, &item.CreatedAt, &item.UpdatedAt, &item.TokenVersion)
return item, classify(err)
}
func (s *Store) FindUserByID(ctx context.Context, id int64) (domain.UserCredential, error) {
var item domain.UserCredential
err := s.pool.QueryRow(ctx, `
SELECT id, username, password_hash, display_name, is_admin, disabled, created_at, updated_at, token_version
FROM users WHERE id=$1`, id).
Scan(&item.ID, &item.Username, &item.PasswordHash, &item.DisplayName, &item.IsAdmin, &item.Disabled, &item.CreatedAt, &item.UpdatedAt, &item.TokenVersion)
return item, classify(err)
}
func (s *Store) ListUsers(ctx context.Context) ([]domain.User, error) {
rows, err := s.pool.Query(ctx, `SELECT id, username, display_name, is_admin, disabled, created_at FROM users ORDER BY username`)
rows, err := s.pool.Query(ctx, `SELECT id, username, display_name, is_admin, disabled, created_at, updated_at FROM users ORDER BY username`)
if err != nil {
return nil, err
}
@@ -560,7 +609,7 @@ func (s *Store) ListUsers(ctx context.Context) ([]domain.User, error) {
result := make([]domain.User, 0)
for rows.Next() {
var item domain.User
if err := rows.Scan(&item.ID, &item.Username, &item.DisplayName, &item.IsAdmin, &item.Disabled, &item.CreatedAt); err != nil {
if err := rows.Scan(&item.ID, &item.Username, &item.DisplayName, &item.IsAdmin, &item.Disabled, &item.CreatedAt, &item.UpdatedAt); err != nil {
return nil, err
}
result = append(result, item)
@@ -572,9 +621,9 @@ func (s *Store) CreateUser(ctx context.Context, input domain.User, passwordHash
err := s.pool.QueryRow(ctx, `
INSERT INTO users(username, password_hash, display_name, is_admin, disabled)
VALUES($1,$2,$3,$4,$5)
RETURNING id, username, display_name, is_admin, disabled, created_at`,
RETURNING id, username, display_name, is_admin, disabled, created_at, updated_at`,
input.Username, passwordHash, input.DisplayName, input.IsAdmin, input.Disabled,
).Scan(&input.ID, &input.Username, &input.DisplayName, &input.IsAdmin, &input.Disabled, &input.CreatedAt)
).Scan(&input.ID, &input.Username, &input.DisplayName, &input.IsAdmin, &input.Disabled, &input.CreatedAt, &input.UpdatedAt)
if err != nil {
return domain.User{}, classify(err)
}
@@ -582,8 +631,108 @@ func (s *Store) CreateUser(ctx context.Context, input domain.User, passwordHash
return input, nil
}
func (s *Store) SetUserDisabled(ctx context.Context, userID int64, disabled bool, updatedBy string) (domain.User, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return domain.User{}, err
}
defer tx.Rollback(ctx) //nolint:errcheck
var user domain.User
err = tx.QueryRow(ctx, `
SELECT id, username, display_name, is_admin, disabled, created_at, updated_at
FROM users WHERE id=$1 FOR UPDATE`, userID).
Scan(&user.ID, &user.Username, &user.DisplayName, &user.IsAdmin, &user.Disabled, &user.CreatedAt, &user.UpdatedAt)
if err != nil {
return domain.User{}, classify(err)
}
if user.Disabled == disabled {
return user, nil
}
if disabled && user.IsAdmin && !user.Disabled {
if err := ensureAnotherActiveAdmin(ctx, tx); err != nil {
return domain.User{}, err
}
}
err = tx.QueryRow(ctx, `
UPDATE users SET disabled=$2, token_version=token_version+1, updated_at=now()
WHERE id=$1
RETURNING id, username, display_name, is_admin, disabled, created_at, updated_at`, userID, disabled).
Scan(&user.ID, &user.Username, &user.DisplayName, &user.IsAdmin, &user.Disabled, &user.CreatedAt, &user.UpdatedAt)
if err != nil {
return domain.User{}, classify(err)
}
action := "enable"
if disabled {
action = "disable"
}
if err := insertAudit(ctx, tx, actor(updatedBy), action, "user", userID, map[string]any{"username": user.Username, "disabled": disabled}); err != nil {
return domain.User{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.User{}, err
}
return user, nil
}
func (s *Store) ResetUserPassword(ctx context.Context, userID int64, passwordHash, updatedBy string) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx) //nolint:errcheck
var username string
err = tx.QueryRow(ctx, `
UPDATE users SET password_hash=$2, token_version=token_version+1, updated_at=now()
WHERE id=$1 RETURNING username`, userID, passwordHash).Scan(&username)
if err != nil {
return classify(err)
}
if err := insertAudit(ctx, tx, actor(updatedBy), "reset_password", "user", userID, map[string]any{"username": username}); err != nil {
return err
}
return tx.Commit(ctx)
}
func (s *Store) DeleteUser(ctx context.Context, userID int64, updatedBy string) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx) //nolint:errcheck
var user domain.User
err = tx.QueryRow(ctx, `
SELECT id, username, display_name, is_admin, disabled, created_at, updated_at
FROM users WHERE id=$1 FOR UPDATE`, userID).
Scan(&user.ID, &user.Username, &user.DisplayName, &user.IsAdmin, &user.Disabled, &user.CreatedAt, &user.UpdatedAt)
if err != nil {
return classify(err)
}
if user.IsAdmin && !user.Disabled {
if err := ensureAnotherActiveAdmin(ctx, tx); err != nil {
return err
}
}
command, err := tx.Exec(ctx, `DELETE FROM users WHERE id=$1`, userID)
if err != nil {
return classify(err)
}
if command.RowsAffected() == 0 {
return storepkg.ErrNotFound
}
if err := insertAudit(ctx, tx, actor(updatedBy), "delete", "user", userID, map[string]any{"username": user.Username, "isAdmin": user.IsAdmin}); err != nil {
return err
}
return tx.Commit(ctx)
}
func (s *Store) SetUserAppRole(ctx context.Context, input domain.UserAppRole) error {
command, err := s.pool.Exec(ctx, `
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx) //nolint:errcheck
command, err := tx.Exec(ctx, `
INSERT INTO user_app_roles(user_id, app_id, role_id)
SELECT $1, $2, r.id FROM roles r WHERE r.name=$3
ON CONFLICT(user_id, app_id) DO UPDATE SET role_id=EXCLUDED.role_id`, input.UserID, input.AppID, input.Role)
@@ -593,20 +742,35 @@ func (s *Store) SetUserAppRole(ctx context.Context, input domain.UserAppRole) er
if command.RowsAffected() == 0 {
return storepkg.ErrNotFound
}
s.audit(ctx, actor(input.UpdatedBy), "update", "user_app_role", input.UserID, input)
return nil
if _, err := tx.Exec(ctx, `UPDATE users SET token_version=token_version+1, updated_at=now() WHERE id=$1`, input.UserID); err != nil {
return classify(err)
}
if err := insertAudit(ctx, tx, actor(input.UpdatedBy), "update", "user_app_role", input.UserID, input); err != nil {
return err
}
return tx.Commit(ctx)
}
func (s *Store) DeleteUserAppRole(ctx context.Context, userID, appID int64, updatedBy string) error {
command, err := s.pool.Exec(ctx, `DELETE FROM user_app_roles WHERE user_id=$1 AND app_id=$2`, userID, appID)
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx) //nolint:errcheck
command, err := tx.Exec(ctx, `DELETE FROM user_app_roles WHERE user_id=$1 AND app_id=$2`, userID, appID)
if err != nil {
return err
}
if command.RowsAffected() == 0 {
return storepkg.ErrNotFound
}
s.audit(ctx, actor(updatedBy), "delete", "user_app_role", userID, map[string]int64{"appId": appID})
return nil
if _, err := tx.Exec(ctx, `UPDATE users SET token_version=token_version+1, updated_at=now() WHERE id=$1`, userID); err != nil {
return classify(err)
}
if err := insertAudit(ctx, tx, actor(updatedBy), "delete", "user_app_role", userID, map[string]int64{"appId": appID}); err != nil {
return err
}
return tx.Commit(ctx)
}
func (s *Store) GetUserAppRole(ctx context.Context, userID, appID int64) (string, error) {
@@ -923,6 +1087,20 @@ func (s *Store) audit(ctx context.Context, updatedBy, action, targetType string,
_, _ = s.pool.Exec(ctx, `INSERT INTO audit_logs(actor, action, target_type, target_id, detail) VALUES($1,$2,$3,$4,$5)`, actor(updatedBy), action, targetType, targetID, toJSON(detail))
}
func ensureAnotherActiveAdmin(ctx context.Context, tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1)`, adminLifecycleLockID); err != nil {
return err
}
var count int
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM users WHERE is_admin=true AND disabled=false`).Scan(&count); err != nil {
return err
}
if count <= 1 {
return storepkg.ErrLastAdmin
}
return nil
}
func insertAudit(ctx context.Context, tx pgx.Tx, updatedBy, action, targetType string, targetID int64, detail any) error {
_, err := tx.Exec(ctx, `INSERT INTO audit_logs(actor, action, target_type, target_id, detail) VALUES($1,$2,$3,$4,$5)`, actor(updatedBy), action, targetType, targetID, toJSON(detail))
return err

View File

@@ -1,6 +1,16 @@
package postgres
import "testing"
import (
"context"
"errors"
"os"
"strconv"
"testing"
"time"
"github.com/longpeng/configcenter/internal/domain"
storepkg "github.com/longpeng/configcenter/internal/store"
)
func TestMigrationChecksum(t *testing.T) {
const want = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
@@ -8,3 +18,105 @@ func TestMigrationChecksum(t *testing.T) {
t.Fatalf("unexpected SHA-256 checksum: got %s, want %s", got, want)
}
}
func TestUserLifecycleAndAuditIntegration(t *testing.T) {
databaseURL := os.Getenv("TEST_DATABASE_URL")
if databaseURL == "" {
t.Skip("TEST_DATABASE_URL is not configured")
}
ctx := context.Background()
repository, err := Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
defer repository.Close()
if err := repository.Migrate(ctx); err != nil {
t.Fatal(err)
}
suffix := strconv.FormatInt(time.Now().UnixNano(), 36)
rootName := "root-" + suffix
backupName := "backup-" + suffix
appCode := "audit-" + suffix
defer func() {
_, _ = repository.pool.Exec(context.Background(), `DELETE FROM users WHERE username IN ($1,$2)`, rootName, backupName)
_, _ = repository.pool.Exec(context.Background(), `DELETE FROM applications WHERE app_code=$1`, appCode)
}()
if err := repository.EnsureBootstrapAdmin(ctx, rootName, "root-hash", "Root"); err != nil {
t.Fatal(err)
}
root, err := repository.FindUserByUsername(ctx, rootName)
if err != nil {
t.Fatal(err)
}
if root.TokenVersion != 1 || root.UpdatedAt.IsZero() {
t.Fatalf("unexpected bootstrap credential: %#v", root)
}
backup, err := repository.CreateUser(ctx, domain.User{Username: backupName, DisplayName: "Backup", IsAdmin: true, UpdatedBy: rootName}, "backup-hash")
if err != nil {
t.Fatal(err)
}
if _, err := repository.SetUserDisabled(ctx, root.ID, true, backupName); err != nil {
t.Fatal(err)
}
rootAfterDisable, err := repository.FindUserByID(ctx, root.ID)
if err != nil {
t.Fatal(err)
}
if !rootAfterDisable.Disabled || rootAfterDisable.TokenVersion != 2 {
t.Fatalf("disable did not rotate token version: %#v", rootAfterDisable)
}
if err := repository.DeleteUser(ctx, backup.ID, rootName); !errors.Is(err, storepkg.ErrLastAdmin) {
t.Fatalf("deleting last active admin must fail: %v", err)
}
if _, err := repository.SetUserDisabled(ctx, root.ID, false, backupName); err != nil {
t.Fatal(err)
}
if err := repository.ResetUserPassword(ctx, backup.ID, "new-backup-hash", rootName); err != nil {
t.Fatal(err)
}
backupCredential, err := repository.FindUserByID(ctx, backup.ID)
if err != nil {
t.Fatal(err)
}
if backupCredential.TokenVersion != 2 || backupCredential.PasswordHash != "new-backup-hash" {
t.Fatalf("password reset did not rotate credential: %#v", backupCredential)
}
app, err := repository.CreateApplication(ctx, domain.Application{Code: appCode, Name: "Audit Test", UpdatedBy: rootName})
if err != nil {
t.Fatal(err)
}
if err := repository.SetUserAppRole(ctx, domain.UserAppRole{UserID: backup.ID, AppID: app.ID, Role: "viewer", UpdatedBy: rootName}); err != nil {
t.Fatal(err)
}
backupCredential, _ = repository.FindUserByID(ctx, backup.ID)
if backupCredential.TokenVersion != 3 {
t.Fatalf("role change did not rotate token version: %d", backupCredential.TokenVersion)
}
if err := repository.DeleteUserAppRole(ctx, backup.ID, app.ID, rootName); err != nil {
t.Fatal(err)
}
backupCredential, _ = repository.FindUserByID(ctx, backup.ID)
if backupCredential.TokenVersion != 4 {
t.Fatalf("role removal did not rotate token version: %d", backupCredential.TokenVersion)
}
page, err := repository.ListAuditLogs(ctx, domain.AuditLogQuery{Actor: rootName, TargetType: "user", Limit: 1})
if err != nil {
t.Fatal(err)
}
if len(page.Items) != 1 || page.NextCursor == nil {
t.Fatalf("expected filtered cursor page: %#v", page)
}
next, err := repository.ListAuditLogs(ctx, domain.AuditLogQuery{Actor: rootName, TargetType: "user", Limit: 10, BeforeID: *page.NextCursor})
if err != nil {
t.Fatal(err)
}
if len(next.Items) == 0 || next.Items[0].ID >= page.Items[0].ID {
t.Fatalf("audit cursor did not advance: first=%#v next=%#v", page.Items, next.Items)
}
}

View File

@@ -12,6 +12,7 @@ var (
ErrConflict = errors.New("resource already exists")
ErrNoPendingChange = errors.New("no pending configuration changes")
ErrInvalidRollback = errors.New("invalid rollback target")
ErrLastAdmin = errors.New("cannot remove or disable the last active administrator")
)
type Store interface {
@@ -44,12 +45,16 @@ type Store interface {
Rollback(context.Context, domain.RollbackRequest) (domain.Release, error)
ListReleases(context.Context, int64, int64, int64) ([]domain.Release, error)
GetRelease(context.Context, int64) (domain.Release, error)
ListAuditLogs(context.Context, int) ([]domain.AuditLog, error)
ListAuditLogs(context.Context, domain.AuditLogQuery) (domain.AuditLogPage, error)
EnsureBootstrapAdmin(context.Context, string, string, string) error
FindUserByUsername(context.Context, string) (domain.UserCredential, error)
FindUserByID(context.Context, int64) (domain.UserCredential, error)
ListUsers(context.Context) ([]domain.User, error)
CreateUser(context.Context, domain.User, string) (domain.User, error)
SetUserDisabled(context.Context, int64, bool, string) (domain.User, error)
ResetUserPassword(context.Context, int64, string, string) error
DeleteUser(context.Context, int64, string) error
SetUserAppRole(context.Context, domain.UserAppRole) error
DeleteUserAppRole(context.Context, int64, int64, string) error
GetUserAppRole(context.Context, int64, int64) (string, error)

View File

@@ -72,6 +72,24 @@ function grayRule(item) {
};
}
function user(item) {
return {
...item,
id: normalizeId(item.id),
createdAt: localTime(item.createdAt),
updatedAt: localTime(item.updatedAt),
};
}
function auditLog(item) {
return {
...item,
id: normalizeId(item.id),
targetId: normalizeId(item.targetId),
createdAt: localTime(item.createdAt),
};
}
async function request(path, options = {}) {
const token = accessToken();
const response = await fetch(`${API_ROOT}${path}`, {
@@ -188,9 +206,23 @@ export const api = {
updateGrayRule: (id, input) => request(`/v1/gray-rules/${id}`, json('PUT', input)).then(grayRule),
deleteGrayRule: (id) => request(`/v1/gray-rules/${id}`, { method: 'DELETE' }),
listUsers: () => request('/v1/users'),
createUser: (input) => request('/v1/users', json('POST', input)),
listUsers: () => request('/v1/users').then((items) => items.map(user)),
createUser: (input) => request('/v1/users', json('POST', input)).then(user),
setUserStatus: (id, disabled) => request(`/v1/users/${id}/status`, json('PUT', { disabled })).then(user),
resetUserPassword: (id, password) => request(`/v1/users/${id}/reset-password`, json('POST', { password })),
deleteUser: (id) => request(`/v1/users/${id}`, { method: 'DELETE' }),
listUserRoles: (id) => request(`/v1/users/${id}/roles`),
setUserRole: (userId, appId, role) => request(`/v1/users/${userId}/roles/${appId}`, json('PUT', { role })),
deleteUserRole: (userId, appId) => request(`/v1/users/${userId}/roles/${appId}`, { method: 'DELETE' }),
async listAuditLogs(filters = {}) {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(filters)) {
if (value !== '' && value != null) query.set(key, value);
}
const page = await request(`/v1/audit-logs?${query.toString()}`);
return {
items: (page.items || []).map(auditLog),
nextCursor: normalizeId(page.nextCursor),
};
},
};