feat: add audit and user lifecycle management
This commit is contained in:
26
.github/workflows/ci.yml
vendored
26
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
244
ConfigCenter.jsx
244
ConfigCenter.jsx
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
12
README.md
12
README.md
@@ -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
|
||||
|
||||
@@ -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、备份恢复、故障演练与生产准入验收。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user