This commit is contained in:
163
ConfigCenter.jsx
163
ConfigCenter.jsx
@@ -623,6 +623,7 @@ const NAV_ITEMS = [
|
||||
{ key: 'namespaces', label: '命名空间管理', icon: FolderTree },
|
||||
{ key: 'environments', label: '环境管理', icon: Globe },
|
||||
{ key: 'users', label: '用户与权限', icon: Users, adminOnly: true },
|
||||
{ key: 'tokens', label: '访问令牌', icon: LockKeyhole, adminOnly: true },
|
||||
{ key: 'audit', label: '审计日志', icon: History, adminOnly: true },
|
||||
];
|
||||
function Sidebar({ nav, setNav, identity, onLogout }) {
|
||||
@@ -667,6 +668,7 @@ const PAGE_META = {
|
||||
namespaces: { title: '命名空间管理', desc: '管理各应用下的配置命名空间' },
|
||||
environments: { title: '环境管理', desc: '管理配置生效的环境' },
|
||||
users: { title: '用户与权限', desc: '管理用户生命周期与应用级 viewer / app-owner 角色' },
|
||||
tokens: { title: '访问令牌', desc: '为 SDK 和服务实例签发可撤销的应用/环境级访问令牌' },
|
||||
audit: { title: '审计日志', desc: '按操作者、动作、资源类型和时间范围追溯管理操作' },
|
||||
};
|
||||
function TopBar({ nav }) {
|
||||
@@ -1274,8 +1276,164 @@ function UsersPage({ users, apps, identity, notify, onCreate, onToggleStatus, on
|
||||
);
|
||||
}
|
||||
|
||||
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 CreateServiceTokenModal({ apps, environments, onClose, onCreated }) {
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
appId: apps[0]?.id || '',
|
||||
envId: environments[0]?.id || '',
|
||||
expiresInDays: '365',
|
||||
read: true,
|
||||
watch: true,
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const set = (key, value) => setForm((current) => ({ ...current, [key]: value }));
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (!form.read && !form.watch) {
|
||||
setError('至少选择一项权限');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onCreated({
|
||||
name: form.name,
|
||||
appId: form.appId,
|
||||
envId: form.envId,
|
||||
expiresInDays: Number(form.expiresInDays),
|
||||
permissions: [form.read && 'config:read', form.watch && 'config:watch'].filter(Boolean),
|
||||
});
|
||||
} 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-lg 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">用于 SDK / 服务实例读取运行时配置</p></div>
|
||||
<button type="button" onClick={onClose}><X size={18} /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<Field label="名称" required><input autoFocus required maxLength={128} value={form.name} onChange={(e) => set('name', e.target.value)} placeholder="如 venus-service-dev" className={inputCls()} /></Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="应用" required><select required value={form.appId} onChange={(e) => set('appId', e.target.value)} className={inputCls()}><option value="" disabled>选择应用</option>{apps.map((item) => <option key={item.id} value={item.id}>{item.code} ({item.name})</option>)}</select></Field>
|
||||
<Field label="环境" required><select required value={form.envId} onChange={(e) => set('envId', e.target.value)} className={inputCls()}><option value="" disabled>选择环境</option>{environments.map((item) => <option key={item.id} value={item.id}>{item.code} ({item.name})</option>)}</select></Field>
|
||||
</div>
|
||||
<Field label="权限" required>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="flex items-start gap-2 rounded-lg border border-slate-200 p-3 cursor-pointer hover:bg-slate-50"><input type="checkbox" className="mt-0.5" checked={form.read} onChange={(e) => set('read', e.target.checked)} /><span><span className="block text-sm text-slate-700">读取配置</span><span className="block text-[11px] text-slate-400 mt-0.5">GET /v1/config</span></span></label>
|
||||
<label className="flex items-start gap-2 rounded-lg border border-slate-200 p-3 cursor-pointer hover:bg-slate-50"><input type="checkbox" className="mt-0.5" checked={form.watch} onChange={(e) => set('watch', e.target.checked)} /><span><span className="block text-sm text-slate-700">Watch 配置</span><span className="block text-[11px] text-slate-400 mt-0.5">SSE / gRPC Watch</span></span></label>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="有效期"><select value={form.expiresInDays} onChange={(e) => set('expiresInDays', e.target.value)} className={inputCls()}><option value="30">30 天</option><option value="90">90 天</option><option value="365">365 天</option><option value="0">永不过期</option></select></Field>
|
||||
<div className="rounded-lg bg-amber-50 border border-amber-100 px-3 py-2.5 text-xs leading-5 text-amber-700">令牌只能访问所选应用与环境的运行时配置,不能修改配置或调用管理 API。生产环境建议设置有效期并定期轮换。</div>
|
||||
{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 || !apps.length || !environments.length} className="px-3 py-1.5 text-sm rounded-lg bg-indigo-600 text-white disabled:opacity-50">{submitting ? '生成中…' : '生成令牌'}</button></div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceTokenSecretModal({ result, onClose, notify }) {
|
||||
async function copyToken() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(result.token);
|
||||
notify('访问令牌已复制');
|
||||
} catch {
|
||||
notify('复制失败,请手动复制令牌', 'danger');
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-slate-900/50 backdrop-blur-sm p-4" onClick={onClose}>
|
||||
<div onClick={(e) => e.stopPropagation()} className="bg-white rounded-xl shadow-xl w-full max-w-xl 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 flex items-center gap-2"><Check size={16} className="text-emerald-500" />访问令牌已生成</h3><p className="text-xs text-rose-500 mt-1">明文只展示这一次,关闭后无法再次查看。</p></div><button onClick={onClose}><X size={18} /></button></div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div><label className="block text-xs text-slate-500 mb-1.5">Token</label><div className="flex gap-2"><textarea readOnly rows={3} value={result.token} className="flex-1 resize-none rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 font-mono text-xs text-slate-700 outline-none" /><button onClick={copyToken} className="self-start px-3 py-2 rounded-lg border border-slate-200 text-sm text-slate-600 hover:bg-slate-50">复制</button></div></div>
|
||||
<div><label className="block text-xs text-slate-500 mb-1.5">Venus 环境变量</label><pre className="rounded-lg bg-slate-900 text-slate-200 p-3 text-xs overflow-x-auto">{`export VENUS_CONFIG_CENTER_TOKEN='${result.token}'`}</pre></div>
|
||||
<div className="rounded-lg bg-slate-50 border border-slate-200 p-3 text-xs text-slate-500">建议存入 Kubernetes Secret、Vault 或部署平台 Secret,不要写入 Git 仓库、镜像或普通配置文件。</div>
|
||||
</div>
|
||||
<div className="px-5 py-3 bg-slate-50 flex justify-end"><button onClick={onClose} className="px-3 py-1.5 text-sm rounded-lg bg-indigo-600 text-white">我已保存,关闭</button></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceTokensPage({ apps, environments, notify }) {
|
||||
const [tokens, setTokens] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [created, setCreated] = useState(null);
|
||||
|
||||
async function reload() {
|
||||
setLoading(true);
|
||||
try { setTokens(await api.listServiceTokens()); }
|
||||
catch (err) { notify(err.message, 'danger'); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
useEffect(() => { reload(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function createToken(input) {
|
||||
const result = await api.createServiceToken(input);
|
||||
setCreateOpen(false);
|
||||
setCreated(result);
|
||||
await reload();
|
||||
}
|
||||
|
||||
async function revokeToken(item) {
|
||||
if (!window.confirm(`确认撤销访问令牌「${item.name}」?撤销后使用该令牌的 SDK 会立即认证失败。`)) return;
|
||||
try {
|
||||
await api.revokeServiceToken(item.id);
|
||||
notify('访问令牌已撤销');
|
||||
await reload();
|
||||
} catch (err) { notify(err.message, 'danger'); }
|
||||
}
|
||||
|
||||
function scope(item) {
|
||||
const app = apps.find((value) => String(value.id) === String(item.appId));
|
||||
const env = environments.find((value) => String(value.id) === String(item.envId));
|
||||
return `${app?.code || `App #${item.appId}`} / ${env?.code || `Env #${item.envId}`}`;
|
||||
}
|
||||
|
||||
function status(item) {
|
||||
if (item.revokedAt) return { label: '已撤销', cls: 'bg-rose-50 text-rose-600' };
|
||||
if (item.expiresAtRaw && new Date(item.expiresAtRaw).getTime() <= Date.now()) return { label: '已过期', cls: 'bg-amber-50 text-amber-600' };
|
||||
return { label: '有效', cls: 'bg-emerald-50 text-emerald-600' };
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-slate-400">用于服务端 SDK 的长期凭证。数据库只保存 Token 哈希,明文仅在创建时展示一次。</p>
|
||||
<button onClick={() => setCreateOpen(true)} 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-[1100px]">
|
||||
<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">Token 前缀</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-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">
|
||||
{!loading && tokens.map((item) => {
|
||||
const currentStatus = status(item);
|
||||
return <tr key={item.id} className="hover:bg-slate-50/60"><td className="px-4 py-3"><div className="font-medium text-slate-700">{item.name}</div><div className="text-[11px] text-slate-400 mt-0.5">创建人:{item.createdBy}</div></td><td className="px-4 py-3 font-mono text-xs text-slate-500">{item.tokenPrefix}…</td><td className="px-4 py-3 font-mono text-xs text-indigo-600">{scope(item)}</td><td className="px-4 py-3"><div className="flex gap-1 flex-wrap">{item.permissions.map((permission) => <span key={permission} className="px-1.5 py-0.5 rounded bg-slate-100 text-[11px] text-slate-600">{permission}</span>)}</div></td><td className="px-4 py-3 text-xs text-slate-500 whitespace-nowrap">{item.expiresAt || '永不过期'}</td><td className="px-4 py-3 text-xs text-slate-500 whitespace-nowrap">{item.lastUsedAt || '尚未使用'}</td><td className="px-4 py-3"><span className={`px-2 py-0.5 rounded text-xs ${currentStatus.cls}`}>{currentStatus.label}</span></td><td className="px-4 py-3 text-right"><button disabled={Boolean(item.revokedAt)} onClick={() => revokeToken(item)} className="px-2 py-1.5 rounded-md border border-slate-200 text-xs text-slate-600 hover:text-rose-600 hover:bg-rose-50 disabled:opacity-35 disabled:cursor-not-allowed">撤销</button></td></tr>;
|
||||
})}
|
||||
{loading && <tr><td colSpan={8} className="px-4 py-10 text-center text-slate-400">正在加载访问令牌…</td></tr>}
|
||||
{!loading && tokens.length === 0 && <tr><td colSpan={8} className="px-4 py-10 text-center text-slate-400">暂无访问令牌</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{createOpen && <CreateServiceTokenModal apps={apps} environments={environments} onClose={() => setCreateOpen(false)} onCreated={createToken} />}
|
||||
{created && <ServiceTokenSecretModal result={created} onClose={() => setCreated(null)} notify={notify} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const AUDIT_ACTIONS = ['', 'create', 'update', 'delete', 'publish', 'rollback', 'enable', 'disable', 'reset_password', 'revoke'];
|
||||
const AUDIT_TARGETS = ['', 'app', 'env', 'namespace', 'config', 'release', 'gray_rule', 'user', 'user_app_role', 'service_token'];
|
||||
|
||||
function AuditPage({ notify }) {
|
||||
const emptyFilters = { actor: '', action: '', targetType: '', from: '', to: '' };
|
||||
@@ -1859,6 +2017,7 @@ export default function ConfigCenter() {
|
||||
onResetPassword={setPasswordResetUser} onDelete={deleteUser}
|
||||
/>
|
||||
)}
|
||||
{!loading && nav === 'tokens' && identity?.user?.isAdmin && <ServiceTokensPage apps={apps} environments={environments} notify={notify} />}
|
||||
{!loading && nav === 'audit' && identity?.user?.isAdmin && <AuditPage notify={notify} />}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -87,7 +87,7 @@ func (s *Server) GetConfig(ctx context.Context, request *configcenterv1.GetConfi
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
if err := s.authorizer.RequireAppRole(ctx, appID, authpkg.RoleViewer); err != nil {
|
||||
if err := s.authorizer.RequireRuntimeAccess(ctx, appID, envID, authpkg.PermissionConfigRead); err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
current, err := s.runtime.Get(ctx, key)
|
||||
@@ -117,7 +117,7 @@ func (s *Server) WatchConfig(request *configcenterv1.WatchConfigRequest, stream
|
||||
if err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
if err := s.authorizer.RequireAppRole(ctx, appID, authpkg.RoleViewer); err != nil {
|
||||
if err := s.authorizer.RequireRuntimeAccess(ctx, appID, envID, authpkg.PermissionConfigWatch); err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
|
||||
|
||||
@@ -201,6 +201,40 @@ func TestAuthenticationRejectsMissingRoleAndExpiredToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceTokenScopesGRPCRuntimeOnly(t *testing.T) {
|
||||
testServer := newTestServer(t, true, 8*time.Hour)
|
||||
putRuntime(t, testServer.runtime, 1, map[string]string{"server.port": "7788"})
|
||||
item, token, err := testServer.authorizer.CreateServiceToken(t.Context(), domain.ServiceToken{
|
||||
Name: "grpc-reader", AppID: 10, EnvironmentID: 1,
|
||||
Permissions: []string{authpkg.PermissionConfigRead}, CreatedBy: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := testServer.config.GetConfig(bearerContext(token), &configcenterv1.GetConfigRequest{Env: "DEV", App: "demo-service", Namespace: "application"})
|
||||
if err != nil || itemValue(response.GetItems(), "server.port") != "7788" {
|
||||
t.Fatalf("service token gRPC read failed: response=%#v err=%v", response, err)
|
||||
}
|
||||
stream, err := testServer.config.WatchConfig(bearerContext(token), &configcenterv1.WatchConfigRequest{Env: "DEV", App: "demo-service", Namespace: "application"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := stream.Recv(); status.Code(err) != codes.PermissionDenied {
|
||||
t.Fatalf("read-only token must not watch: %v", err)
|
||||
}
|
||||
_, err = testServer.admin.PublishConfig(bearerContext(token), &configcenterv1.PublishRequest{EnvId: 1, AppId: 10, NamespaceId: 20})
|
||||
if status.Code(err) != codes.PermissionDenied {
|
||||
t.Fatalf("service token must not call admin service: %v", err)
|
||||
}
|
||||
if err := testServer.repository.RevokeServiceToken(t.Context(), item.ID, "admin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = testServer.config.GetConfig(bearerContext(token), &configcenterv1.GetConfigRequest{Env: "DEV", App: "demo-service", Namespace: "application"})
|
||||
if status.Code(err) != codes.Unauthenticated {
|
||||
t.Fatalf("revoked service token must be unauthenticated: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type grpcTestServer struct {
|
||||
config configcenterv1.ConfigServiceClient
|
||||
admin configcenterv1.AdminServiceClient
|
||||
|
||||
@@ -74,6 +74,9 @@ func (s *Server) routes() {
|
||||
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)
|
||||
s.mux.HandleFunc("GET /v1/service-tokens", s.listServiceTokens)
|
||||
s.mux.HandleFunc("POST /v1/service-tokens", s.createServiceToken)
|
||||
s.mux.HandleFunc("DELETE /v1/service-tokens/{id}", s.revokeServiceToken)
|
||||
|
||||
s.mux.HandleFunc("GET /v1/applications", s.listApplications)
|
||||
s.mux.HandleFunc("POST /v1/applications", s.createApplication)
|
||||
@@ -300,6 +303,67 @@ func (s *Server) deleteUserRole(w http.ResponseWriter, r *http.Request) {
|
||||
s.respondEmpty(w, s.store.DeleteUserAppRole(r.Context(), userID, appID, actor(r)))
|
||||
}
|
||||
|
||||
func (s *Server) listServiceTokens(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
items, err := s.store.ListServiceTokens(r.Context())
|
||||
s.respond(w, items, err, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) createServiceToken(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Name string `json:"name"`
|
||||
AppID int64 `json:"appId"`
|
||||
EnvironmentID int64 `json:"envId"`
|
||||
Permissions []string `json:"permissions"`
|
||||
ExpiresInDays int `json:"expiresInDays"`
|
||||
}
|
||||
if !decode(w, r, &input) {
|
||||
return
|
||||
}
|
||||
if input.ExpiresInDays < 0 || input.ExpiresInDays > 3650 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_argument", "令牌有效期必须为 0 到 3650 天;0 表示永不过期")
|
||||
return
|
||||
}
|
||||
var expiresAt *time.Time
|
||||
if input.ExpiresInDays > 0 {
|
||||
value := time.Now().UTC().AddDate(0, 0, input.ExpiresInDays)
|
||||
expiresAt = &value
|
||||
}
|
||||
item, token, err := s.authorizer.CreateServiceToken(r.Context(), domain.ServiceToken{
|
||||
Name: input.Name,
|
||||
AppID: input.AppID,
|
||||
EnvironmentID: input.EnvironmentID,
|
||||
Permissions: input.Permissions,
|
||||
ExpiresAt: expiresAt,
|
||||
CreatedBy: actor(r),
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "service token") || strings.Contains(err.Error(), "permission") {
|
||||
writeError(w, http.StatusBadRequest, "invalid_argument", err.Error())
|
||||
return
|
||||
}
|
||||
s.respond(w, nil, err, http.StatusCreated)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, responseEnvelope{Data: map[string]any{"item": item, "token": token}})
|
||||
}
|
||||
|
||||
func (s *Server) revokeServiceToken(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
id, ok := pathID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.respondEmpty(w, s.store.RevokeServiceToken(r.Context(), id, actor(r)))
|
||||
}
|
||||
|
||||
func (s *Server) listApplications(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.store.ListApplications(r.Context())
|
||||
if err == nil {
|
||||
@@ -777,7 +841,7 @@ func (s *Server) getRuntimeConfig(w http.ResponseWriter, r *http.Request) {
|
||||
s.respond(w, nil, err, http.StatusOK)
|
||||
return
|
||||
}
|
||||
if !s.requireApp(w, r, appID, authpkg.RoleViewer) {
|
||||
if !s.requireRuntime(w, r, appID, envID, authpkg.PermissionConfigRead) {
|
||||
return
|
||||
}
|
||||
item, err := s.runtime.Get(r.Context(), key)
|
||||
@@ -804,7 +868,7 @@ func (s *Server) watchRuntimeConfig(w http.ResponseWriter, r *http.Request) {
|
||||
s.respond(w, nil, err, http.StatusOK)
|
||||
return
|
||||
}
|
||||
if !s.requireApp(w, r, appID, authpkg.RoleViewer) {
|
||||
if !s.requireRuntime(w, r, appID, envID, authpkg.PermissionConfigWatch) {
|
||||
return
|
||||
}
|
||||
flusher, ok := w.(http.Flusher)
|
||||
@@ -970,6 +1034,18 @@ func (s *Server) requireApp(w http.ResponseWriter, r *http.Request, appID int64,
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) requireRuntime(w http.ResponseWriter, r *http.Request, appID, envID int64, permission string) bool {
|
||||
if err := s.authorizer.RequireRuntimeAccess(r.Context(), appID, envID, permission); err != nil {
|
||||
if errors.Is(err, authpkg.ErrUnauthorized) || errors.Is(err, authpkg.ErrForbidden) {
|
||||
s.writeAuthError(w, err)
|
||||
} else {
|
||||
s.respond(w, nil, err, http.StatusOK)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) authenticate(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodOptions || r.URL.Path == "/v1/auth/login" || strings.HasPrefix(r.URL.Path, "/health/") || r.URL.Path == "/metrics" {
|
||||
@@ -986,6 +1062,10 @@ func (s *Server) authenticate(next http.Handler) http.Handler {
|
||||
principal.Username, principal.DisplayName = value, value
|
||||
}
|
||||
}
|
||||
if principal.AuthType == authpkg.AuthTypeServiceToken && r.URL.Path != "/v1/config" && r.URL.Path != "/v1/watch" {
|
||||
s.writeAuthError(w, authpkg.ErrForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(authpkg.WithPrincipal(r.Context(), principal)))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -296,6 +296,99 @@ func TestUserLifecycleInvalidatesTokensAndAuditPagination(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceTokenRuntimeAccessAndRevocation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
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)
|
||||
}
|
||||
collector := metrics.New()
|
||||
worker := outbox.New(repository, runtimeStore, time.Millisecond, 10, 3, collector, logger)
|
||||
go worker.Run(ctx)
|
||||
handler := httpapi.New(repository, runtimeStore, watch.New(ctx, runtimeStore), authorizer, collector, 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 app domain.Application
|
||||
requestJSON(t, handler, http.MethodPost, "/v1/applications", rootLogin.Token, map[string]any{"code": "venus-service", "name": "Venus"}, http.StatusCreated, &app)
|
||||
var dev domain.Environment
|
||||
requestJSON(t, handler, http.MethodPost, "/v1/environments", rootLogin.Token, map[string]any{"code": "DEV", "name": "Development"}, http.StatusCreated, &dev)
|
||||
var testEnv domain.Environment
|
||||
requestJSON(t, handler, http.MethodPost, "/v1/environments", rootLogin.Token, map[string]any{"code": "TEST", "name": "Test"}, http.StatusCreated, &testEnv)
|
||||
var namespace domain.Namespace
|
||||
requestJSON(t, handler, http.MethodPost, "/v1/namespaces", rootLogin.Token, map[string]any{"appId": app.ID, "name": "default", "type": "properties"}, http.StatusCreated, &namespace)
|
||||
requestJSON(t, handler, http.MethodPost, "/v1/config-items", rootLogin.Token, map[string]any{
|
||||
"appId": app.ID, "nsId": namespace.ID, "envId": dev.ID, "key": "server.port", "value": "7788",
|
||||
}, http.StatusCreated, &domain.ConfigItem{})
|
||||
var release domain.Release
|
||||
requestJSON(t, handler, http.MethodPost, "/v1/publish", rootLogin.Token, map[string]any{
|
||||
"appId": app.ID, "nsId": namespace.ID, "envId": dev.ID, "comment": "service token test",
|
||||
}, http.StatusAccepted, &release)
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
requestJSON(t, handler, http.MethodGet, fmt.Sprintf("/v1/releases/%d", release.ID), rootLogin.Token, nil, http.StatusOK, &release)
|
||||
if release.Status == "applied" {
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if release.Status != "applied" {
|
||||
t.Fatalf("release was not applied: %#v", release)
|
||||
}
|
||||
|
||||
var created struct {
|
||||
Item domain.ServiceToken `json:"item"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
requestJSON(t, handler, http.MethodPost, "/v1/service-tokens", rootLogin.Token, map[string]any{
|
||||
"name": "venus-dev", "appId": app.ID, "envId": dev.ID,
|
||||
"permissions": []string{auth.PermissionConfigRead, auth.PermissionConfigWatch}, "expiresInDays": 30,
|
||||
}, http.StatusCreated, &created)
|
||||
if !strings.HasPrefix(created.Token, "cc_pat_") || created.Item.TokenPrefix == "" || !strings.HasPrefix(created.Token, created.Item.TokenPrefix) {
|
||||
t.Fatalf("unexpected service token response: %#v", created.Item)
|
||||
}
|
||||
|
||||
var current domain.RuntimeConfig
|
||||
requestJSON(t, handler, http.MethodGet, "/v1/config?env=DEV&app=venus-service&namespace=default", created.Token, nil, http.StatusOK, ¤t)
|
||||
if current.Items["server.port"] != "7788" {
|
||||
t.Fatalf("service token runtime read failed: %#v", current)
|
||||
}
|
||||
requestJSON(t, handler, http.MethodGet, "/v1/config?env=TEST&app=venus-service&namespace=default", created.Token, nil, http.StatusForbidden, nil)
|
||||
requestJSON(t, handler, http.MethodGet, "/v1/applications", created.Token, nil, http.StatusForbidden, nil)
|
||||
|
||||
var tokens []domain.ServiceToken
|
||||
requestJSON(t, handler, http.MethodGet, "/v1/service-tokens", rootLogin.Token, nil, http.StatusOK, &tokens)
|
||||
if len(tokens) != 1 || tokens[0].ID != created.Item.ID || tokens[0].LastUsedAt == nil {
|
||||
t.Fatalf("unexpected token list: %#v", tokens)
|
||||
}
|
||||
encoded, err := json.Marshal(tokens)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(encoded, []byte(created.Token)) {
|
||||
t.Fatal("service token secret must never be returned by list API")
|
||||
}
|
||||
|
||||
requestJSON(t, handler, http.MethodDelete, fmt.Sprintf("/v1/service-tokens/%d", created.Item.ID), rootLogin.Token, nil, http.StatusNoContent, nil)
|
||||
requestJSON(t, handler, http.MethodGet, "/v1/config?env=DEV&app=venus-service&namespace=default", created.Token, nil, http.StatusUnauthorized, nil)
|
||||
|
||||
var audits domain.AuditLogPage
|
||||
requestJSON(t, handler, http.MethodGet, "/v1/audit-logs?targetType=service_token&limit=10", rootLogin.Token, nil, http.StatusOK, &audits)
|
||||
if len(audits.Items) != 2 || audits.Items[0].Action != "revoke" || audits.Items[1].Action != "create" {
|
||||
t.Fatalf("service token audit trail is incomplete: %#v", audits.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func post(t *testing.T, handler http.Handler, path string, input any, wantStatus int, output any) {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(input)
|
||||
|
||||
@@ -3,12 +3,15 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -27,6 +30,14 @@ const (
|
||||
RoleViewer = "viewer"
|
||||
RoleAppOwner = "app-owner"
|
||||
RoleAdmin = "admin"
|
||||
|
||||
AuthTypeUser = "user"
|
||||
AuthTypeServiceToken = "service-token"
|
||||
|
||||
PermissionConfigRead = "config:read"
|
||||
PermissionConfigWatch = "config:watch"
|
||||
|
||||
serviceTokenPrefix = "cc_pat_"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
@@ -80,7 +91,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}
|
||||
principal := domain.Principal{Username: "admin", DisplayName: "Development Admin", IsAdmin: true, AuthType: AuthTypeUser}
|
||||
token, err := m.issue(principal, 0)
|
||||
return token, principal, err
|
||||
}
|
||||
@@ -88,7 +99,7 @@ func (m *Manager) Login(ctx context.Context, username, password string) (string,
|
||||
if err != nil || credential.Disabled || bcrypt.CompareHashAndPassword([]byte(credential.PasswordHash), []byte(password)) != nil {
|
||||
return "", domain.Principal{}, ErrUnauthorized
|
||||
}
|
||||
principal := domain.Principal{UserID: credential.ID, Username: credential.Username, DisplayName: credential.DisplayName, IsAdmin: credential.IsAdmin}
|
||||
principal := domain.Principal{UserID: credential.ID, Username: credential.Username, DisplayName: credential.DisplayName, IsAdmin: credential.IsAdmin, AuthType: AuthTypeUser}
|
||||
token, err := m.issue(principal, credential.TokenVersion)
|
||||
return token, principal, err
|
||||
}
|
||||
@@ -116,6 +127,29 @@ func (m *Manager) ResetUserPassword(ctx context.Context, userID int64, password,
|
||||
return m.store.ResetUserPassword(ctx, userID, string(hash), updatedBy)
|
||||
}
|
||||
|
||||
func (m *Manager) CreateServiceToken(ctx context.Context, input domain.ServiceToken) (domain.ServiceToken, string, error) {
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
if input.Name == "" || len(input.Name) > 128 || input.AppID <= 0 || input.EnvironmentID <= 0 {
|
||||
return domain.ServiceToken{}, "", errors.New("service token name, app and environment are required")
|
||||
}
|
||||
permissions, err := normalizeServicePermissions(input.Permissions)
|
||||
if err != nil {
|
||||
return domain.ServiceToken{}, "", err
|
||||
}
|
||||
secret := make([]byte, 32)
|
||||
if _, err := rand.Read(secret); err != nil {
|
||||
return domain.ServiceToken{}, "", fmt.Errorf("generate service token: %w", err)
|
||||
}
|
||||
rawToken := serviceTokenPrefix + base64.RawURLEncoding.EncodeToString(secret)
|
||||
input.TokenPrefix = rawToken[:min(len(rawToken), len(serviceTokenPrefix)+8)]
|
||||
input.Permissions = permissions
|
||||
created, err := m.store.CreateServiceToken(ctx, input, serviceTokenHash(rawToken))
|
||||
if err != nil {
|
||||
return domain.ServiceToken{}, "", err
|
||||
}
|
||||
return created, rawToken, nil
|
||||
}
|
||||
|
||||
func (m *Manager) AuthenticateRequest(r *http.Request) (domain.Principal, error) {
|
||||
header := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
if !m.enabled {
|
||||
@@ -129,12 +163,16 @@ func (m *Manager) AuthenticateRequest(r *http.Request) (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
|
||||
return domain.Principal{Username: "admin", DisplayName: "Development Admin", IsAdmin: true, AuthType: AuthTypeUser}, nil
|
||||
}
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return domain.Principal{}, ErrUnauthorized
|
||||
}
|
||||
return m.parse(ctx, strings.TrimSpace(token))
|
||||
token = strings.TrimSpace(token)
|
||||
if strings.HasPrefix(token, serviceTokenPrefix) {
|
||||
return m.authenticateServiceToken(ctx, token)
|
||||
}
|
||||
return m.parse(ctx, token)
|
||||
}
|
||||
|
||||
func WithPrincipal(ctx context.Context, principal domain.Principal) context.Context {
|
||||
@@ -162,6 +200,9 @@ func (m *Manager) RequireAppRole(ctx context.Context, appID int64, minimum strin
|
||||
if !ok {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
if principal.AuthType == AuthTypeServiceToken {
|
||||
return ErrForbidden
|
||||
}
|
||||
if principal.IsAdmin {
|
||||
return nil
|
||||
}
|
||||
@@ -182,6 +223,25 @@ func (m *Manager) CanAccessApp(ctx context.Context, appID int64, minimum string)
|
||||
return m.RequireAppRole(ctx, appID, minimum) == nil
|
||||
}
|
||||
|
||||
func (m *Manager) RequireRuntimeAccess(ctx context.Context, appID, envID int64, permission string) error {
|
||||
principal, ok := Principal(ctx)
|
||||
if !ok {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
if principal.AuthType != AuthTypeServiceToken {
|
||||
return m.RequireAppRole(ctx, appID, RoleViewer)
|
||||
}
|
||||
if principal.AppID != appID || principal.EnvironmentID != envID || !hasPermission(principal.Permissions, permission) {
|
||||
return ErrForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func IsServicePrincipal(ctx context.Context) bool {
|
||||
principal, ok := Principal(ctx)
|
||||
return ok && principal.AuthType == AuthTypeServiceToken
|
||||
}
|
||||
|
||||
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, Version: version, IssuedAt: now.Unix(), ExpiresAt: now.Add(m.ttl).Unix()}
|
||||
@@ -195,6 +255,32 @@ func (m *Manager) issue(principal domain.Principal, version int64) (string, erro
|
||||
return unsigned + "." + encode(signature), nil
|
||||
}
|
||||
|
||||
func (m *Manager) authenticateServiceToken(ctx context.Context, rawToken string) (domain.Principal, error) {
|
||||
credential, err := m.store.FindServiceTokenByHash(ctx, serviceTokenHash(rawToken))
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return domain.Principal{}, ErrUnauthorized
|
||||
}
|
||||
return domain.Principal{}, err
|
||||
}
|
||||
now := m.now().UTC()
|
||||
if credential.RevokedAt != nil || (credential.ExpiresAt != nil && !credential.ExpiresAt.After(now)) {
|
||||
return domain.Principal{}, ErrUnauthorized
|
||||
}
|
||||
if err := m.store.TouchServiceToken(ctx, credential.ID); err != nil {
|
||||
return domain.Principal{}, err
|
||||
}
|
||||
return domain.Principal{
|
||||
Username: "service-token:" + credential.Name,
|
||||
DisplayName: credential.Name,
|
||||
AuthType: AuthTypeServiceToken,
|
||||
ServiceTokenID: credential.ID,
|
||||
AppID: credential.AppID,
|
||||
EnvironmentID: credential.EnvironmentID,
|
||||
Permissions: slices.Clone(credential.Permissions),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) parse(ctx context.Context, token string) (domain.Principal, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
@@ -227,7 +313,7 @@ func (m *Manager) parse(ctx context.Context, token string) (domain.Principal, er
|
||||
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
|
||||
return domain.Principal{UserID: credential.ID, Username: credential.Username, DisplayName: credential.DisplayName, IsAdmin: credential.IsAdmin, AuthType: AuthTypeUser}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) sign(input string) []byte {
|
||||
@@ -240,6 +326,38 @@ func encode(value []byte) string { return base64.RawURLEncoding.EncodeToString(v
|
||||
|
||||
func normalizeUsername(value string) string { return strings.ToLower(strings.TrimSpace(value)) }
|
||||
|
||||
func serviceTokenHash(rawToken string) string {
|
||||
sum := sha256.Sum256([]byte(rawToken))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func normalizeServicePermissions(input []string) ([]string, error) {
|
||||
seen := make(map[string]struct{}, len(input))
|
||||
for _, permission := range input {
|
||||
permission = strings.TrimSpace(permission)
|
||||
switch permission {
|
||||
case PermissionConfigRead, PermissionConfigWatch:
|
||||
seen[permission] = struct{}{}
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid service token permission %q", permission)
|
||||
}
|
||||
}
|
||||
result := make([]string, 0, len(seen))
|
||||
for _, permission := range []string{PermissionConfigRead, PermissionConfigWatch} {
|
||||
if _, ok := seen[permission]; ok {
|
||||
result = append(result, permission)
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil, errors.New("at least one service token permission is required")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func hasPermission(permissions []string, wanted string) bool {
|
||||
return slices.Contains(permissions, wanted)
|
||||
}
|
||||
|
||||
func roleRank(role string) int {
|
||||
switch role {
|
||||
case RoleViewer:
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -70,3 +71,84 @@ func TestLoginTokenExpiryAndApplicationRoles(t *testing.T) {
|
||||
t.Fatalf("tampered token must be rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceTokenLifecycleAndRuntimeScope(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
repository := memory_store.New(false)
|
||||
manager, err := New(repository, true, "0123456789abcdef0123456789abcdef", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock := time.Date(2026, 8, 30, 3, 0, 0, 0, time.UTC)
|
||||
manager.now = func() time.Time { return clock }
|
||||
app, err := repository.CreateApplication(ctx, domain.Application{Code: "venus", Name: "Venus"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env, err := repository.CreateEnvironment(ctx, domain.Environment{Code: "DEV", Name: "Dev"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
otherEnv, err := repository.CreateEnvironment(ctx, domain.Environment{Code: "TEST", Name: "Test"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expiresAt := clock.Add(30 * 24 * time.Hour)
|
||||
item, rawToken, err := manager.CreateServiceToken(ctx, domain.ServiceToken{
|
||||
Name: "venus-dev",
|
||||
AppID: app.ID,
|
||||
EnvironmentID: env.ID,
|
||||
Permissions: []string{PermissionConfigRead},
|
||||
ExpiresAt: &expiresAt,
|
||||
CreatedBy: "root",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(rawToken, "cc_pat_") || rawToken == item.TokenPrefix || !strings.HasPrefix(rawToken, item.TokenPrefix) {
|
||||
t.Fatalf("unexpected service token presentation: prefix=%q raw-prefix-ok=%v", item.TokenPrefix, strings.HasPrefix(rawToken, "cc_pat_"))
|
||||
}
|
||||
principal, err := manager.AuthenticateToken(ctx, rawToken)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if principal.AuthType != AuthTypeServiceToken || principal.ServiceTokenID != item.ID || principal.AppID != app.ID || principal.EnvironmentID != env.ID {
|
||||
t.Fatalf("unexpected service principal: %#v", principal)
|
||||
}
|
||||
serviceCtx := WithPrincipal(ctx, principal)
|
||||
if err := manager.RequireRuntimeAccess(serviceCtx, app.ID, env.ID, PermissionConfigRead); err != nil {
|
||||
t.Fatalf("read permission must be allowed: %v", err)
|
||||
}
|
||||
if err := manager.RequireRuntimeAccess(serviceCtx, app.ID, env.ID, PermissionConfigWatch); !errors.Is(err, ErrForbidden) {
|
||||
t.Fatalf("watch permission must be denied: %v", err)
|
||||
}
|
||||
if err := manager.RequireRuntimeAccess(serviceCtx, app.ID, otherEnv.ID, PermissionConfigRead); !errors.Is(err, ErrForbidden) {
|
||||
t.Fatalf("cross-environment access must be denied: %v", err)
|
||||
}
|
||||
if err := manager.RequireAppRole(serviceCtx, app.ID, RoleViewer); !errors.Is(err, ErrForbidden) {
|
||||
t.Fatalf("service token must not inherit management viewer access: %v", err)
|
||||
}
|
||||
listed, err := repository.ListServiceTokens(ctx)
|
||||
if err != nil || len(listed) != 1 || listed[0].LastUsedAt == nil {
|
||||
t.Fatalf("service token last-used tracking failed: items=%#v err=%v", listed, err)
|
||||
}
|
||||
if err := repository.RevokeServiceToken(ctx, item.ID, "root"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := manager.AuthenticateToken(ctx, rawToken); !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("revoked token must be rejected: %v", err)
|
||||
}
|
||||
|
||||
shortExpiry := clock.Add(time.Minute)
|
||||
_, expiringToken, err := manager.CreateServiceToken(ctx, domain.ServiceToken{
|
||||
Name: "short-lived", AppID: app.ID, EnvironmentID: env.ID,
|
||||
Permissions: []string{PermissionConfigRead, PermissionConfigWatch}, ExpiresAt: &shortExpiry, CreatedBy: "root",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock = clock.Add(2 * time.Minute)
|
||||
if _, err := manager.AuthenticateToken(ctx, expiringToken); !errors.Is(err, ErrUnauthorized) {
|
||||
t.Fatalf("expired service token must be rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,10 +159,34 @@ type UserAppRole struct {
|
||||
}
|
||||
|
||||
type Principal struct {
|
||||
UserID int64 `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
UserID int64 `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
AuthType string `json:"authType,omitempty"`
|
||||
ServiceTokenID int64 `json:"serviceTokenId,omitempty"`
|
||||
AppID int64 `json:"appId,omitempty"`
|
||||
EnvironmentID int64 `json:"envId,omitempty"`
|
||||
Permissions []string `json:"permissions,omitempty"`
|
||||
}
|
||||
|
||||
type ServiceToken struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TokenPrefix string `json:"tokenPrefix"`
|
||||
AppID int64 `json:"appId"`
|
||||
EnvironmentID int64 `json:"envId"`
|
||||
Permissions []string `json:"permissions"`
|
||||
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
||||
RevokedAt *time.Time `json:"revokedAt,omitempty"`
|
||||
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
}
|
||||
|
||||
type ServiceTokenCredential struct {
|
||||
ServiceToken
|
||||
TokenHash string `json:"-"`
|
||||
}
|
||||
|
||||
type GrayRule struct {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -15,18 +16,19 @@ import (
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
nextID int64
|
||||
applications map[int64]domain.Application
|
||||
environments map[int64]domain.Environment
|
||||
namespaces map[int64]domain.Namespace
|
||||
configs map[int64]domain.ConfigItem
|
||||
releases map[int64]domain.Release
|
||||
outbox map[int64]outboxRecord
|
||||
audits []domain.AuditLog
|
||||
users map[int64]domain.UserCredential
|
||||
userRoles map[[2]int64]string
|
||||
grayRules map[int64]domain.GrayRule
|
||||
mu sync.RWMutex
|
||||
nextID int64
|
||||
applications map[int64]domain.Application
|
||||
environments map[int64]domain.Environment
|
||||
namespaces map[int64]domain.Namespace
|
||||
configs map[int64]domain.ConfigItem
|
||||
releases map[int64]domain.Release
|
||||
outbox map[int64]outboxRecord
|
||||
audits []domain.AuditLog
|
||||
users map[int64]domain.UserCredential
|
||||
userRoles map[[2]int64]string
|
||||
serviceTokens map[int64]domain.ServiceTokenCredential
|
||||
grayRules map[int64]domain.GrayRule
|
||||
}
|
||||
|
||||
type outboxRecord struct {
|
||||
@@ -37,16 +39,17 @@ type outboxRecord struct {
|
||||
|
||||
func New(withSeed bool) *Store {
|
||||
s := &Store{
|
||||
nextID: 100,
|
||||
applications: make(map[int64]domain.Application),
|
||||
environments: make(map[int64]domain.Environment),
|
||||
namespaces: make(map[int64]domain.Namespace),
|
||||
configs: make(map[int64]domain.ConfigItem),
|
||||
releases: make(map[int64]domain.Release),
|
||||
outbox: make(map[int64]outboxRecord),
|
||||
users: make(map[int64]domain.UserCredential),
|
||||
userRoles: make(map[[2]int64]string),
|
||||
grayRules: make(map[int64]domain.GrayRule),
|
||||
nextID: 100,
|
||||
applications: make(map[int64]domain.Application),
|
||||
environments: make(map[int64]domain.Environment),
|
||||
namespaces: make(map[int64]domain.Namespace),
|
||||
configs: make(map[int64]domain.ConfigItem),
|
||||
releases: make(map[int64]domain.Release),
|
||||
outbox: make(map[int64]outboxRecord),
|
||||
users: make(map[int64]domain.UserCredential),
|
||||
userRoles: make(map[[2]int64]string),
|
||||
serviceTokens: make(map[int64]domain.ServiceTokenCredential),
|
||||
grayRules: make(map[int64]domain.GrayRule),
|
||||
}
|
||||
if withSeed {
|
||||
s.seed()
|
||||
@@ -158,6 +161,11 @@ func (s *Store) DeleteApplication(_ context.Context, id int64, actor string) err
|
||||
delete(s.userRoles, key)
|
||||
}
|
||||
}
|
||||
for tokenID, token := range s.serviceTokens {
|
||||
if token.AppID == id {
|
||||
delete(s.serviceTokens, tokenID)
|
||||
}
|
||||
}
|
||||
s.audit(defaultActor(actor), "delete", "app", id, nil)
|
||||
return nil
|
||||
}
|
||||
@@ -218,6 +226,11 @@ func (s *Store) DeleteEnvironment(_ context.Context, id int64, actor string) err
|
||||
delete(s.configs, configID)
|
||||
}
|
||||
}
|
||||
for tokenID, token := range s.serviceTokens {
|
||||
if token.EnvironmentID == id {
|
||||
delete(s.serviceTokens, tokenID)
|
||||
}
|
||||
}
|
||||
for releaseID, item := range s.releases {
|
||||
if item.EnvironmentID == id {
|
||||
delete(s.releases, releaseID)
|
||||
@@ -729,6 +742,88 @@ func (s *Store) ListUserAppRoles(_ context.Context, userID int64) ([]domain.User
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListServiceTokens(context.Context) ([]domain.ServiceToken, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
result := make([]domain.ServiceToken, 0, len(s.serviceTokens))
|
||||
for _, credential := range s.serviceTokens {
|
||||
result = append(result, cloneServiceToken(credential.ServiceToken))
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].CreatedAt.Equal(result[j].CreatedAt) {
|
||||
return result[i].ID > result[j].ID
|
||||
}
|
||||
return result[i].CreatedAt.After(result[j].CreatedAt)
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateServiceToken(_ context.Context, input domain.ServiceToken, tokenHash string) (domain.ServiceToken, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.applications[input.AppID]; !ok {
|
||||
return domain.ServiceToken{}, storepkg.ErrNotFound
|
||||
}
|
||||
if _, ok := s.environments[input.EnvironmentID]; !ok {
|
||||
return domain.ServiceToken{}, storepkg.ErrNotFound
|
||||
}
|
||||
for _, credential := range s.serviceTokens {
|
||||
if credential.TokenHash == tokenHash {
|
||||
return domain.ServiceToken{}, storepkg.ErrConflict
|
||||
}
|
||||
}
|
||||
input.ID = s.id()
|
||||
input.CreatedAt = time.Now().UTC()
|
||||
input.Permissions = slices.Clone(input.Permissions)
|
||||
s.serviceTokens[input.ID] = domain.ServiceTokenCredential{ServiceToken: cloneServiceToken(input), TokenHash: tokenHash}
|
||||
s.audit(defaultActor(input.CreatedBy), "create", "service_token", input.ID, map[string]any{
|
||||
"name": input.Name, "appId": input.AppID, "envId": input.EnvironmentID, "permissions": input.Permissions, "expiresAt": input.ExpiresAt,
|
||||
})
|
||||
return cloneServiceToken(input), nil
|
||||
}
|
||||
|
||||
func (s *Store) FindServiceTokenByHash(_ context.Context, tokenHash string) (domain.ServiceTokenCredential, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, credential := range s.serviceTokens {
|
||||
if credential.TokenHash == tokenHash {
|
||||
credential.ServiceToken = cloneServiceToken(credential.ServiceToken)
|
||||
return credential, nil
|
||||
}
|
||||
}
|
||||
return domain.ServiceTokenCredential{}, storepkg.ErrNotFound
|
||||
}
|
||||
|
||||
func (s *Store) TouchServiceToken(_ context.Context, id int64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
credential, ok := s.serviceTokens[id]
|
||||
if !ok {
|
||||
return storepkg.ErrNotFound
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
credential.LastUsedAt = &now
|
||||
s.serviceTokens[id] = credential
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) RevokeServiceToken(_ context.Context, id int64, updatedBy string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
credential, ok := s.serviceTokens[id]
|
||||
if !ok {
|
||||
return storepkg.ErrNotFound
|
||||
}
|
||||
if credential.RevokedAt != nil {
|
||||
return nil
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
credential.RevokedAt = &now
|
||||
s.serviceTokens[id] = credential
|
||||
s.audit(defaultActor(updatedBy), "revoke", "service_token", id, map[string]any{"name": credential.Name})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) activeAdminCountLocked() int {
|
||||
count := 0
|
||||
for _, credential := range s.users {
|
||||
@@ -739,6 +834,23 @@ func (s *Store) activeAdminCountLocked() int {
|
||||
return count
|
||||
}
|
||||
|
||||
func cloneServiceToken(input domain.ServiceToken) domain.ServiceToken {
|
||||
input.Permissions = slices.Clone(input.Permissions)
|
||||
if input.ExpiresAt != nil {
|
||||
value := *input.ExpiresAt
|
||||
input.ExpiresAt = &value
|
||||
}
|
||||
if input.RevokedAt != nil {
|
||||
value := *input.RevokedAt
|
||||
input.RevokedAt = &value
|
||||
}
|
||||
if input.LastUsedAt != nil {
|
||||
value := *input.LastUsedAt
|
||||
input.LastUsedAt = &value
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func (s *Store) ResolveScope(_ context.Context, envCode, appCode, namespaceName string) (int64, int64, int64, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
21
internal/store/postgres/migrations/004_service_tokens.sql
Normal file
21
internal/store/postgres/migrations/004_service_tokens.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
CREATE TABLE IF NOT EXISTS service_tokens (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
token_hash VARCHAR(64) UNIQUE NOT NULL,
|
||||
token_prefix VARCHAR(32) NOT NULL,
|
||||
app_id BIGINT NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
|
||||
env_id BIGINT NOT NULL REFERENCES environments(id) ON DELETE CASCADE,
|
||||
permissions TEXT[] NOT NULL,
|
||||
expires_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by VARCHAR(64) NOT NULL,
|
||||
CONSTRAINT service_tokens_permissions_check CHECK (
|
||||
cardinality(permissions) > 0
|
||||
AND permissions <@ ARRAY['config:read','config:watch']::TEXT[]
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS service_tokens_scope_idx ON service_tokens(app_id, env_id, revoked_at);
|
||||
CREATE INDEX IF NOT EXISTS service_tokens_created_idx ON service_tokens(created_at DESC);
|
||||
@@ -813,6 +813,95 @@ func (s *Store) ListUserAppRoles(ctx context.Context, userID int64) ([]domain.Us
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListServiceTokens(ctx context.Context) ([]domain.ServiceToken, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, name, token_prefix, app_id, env_id, permissions, expires_at, revoked_at, last_used_at, created_at, created_by
|
||||
FROM service_tokens ORDER BY created_at DESC, id DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]domain.ServiceToken, 0)
|
||||
for rows.Next() {
|
||||
var item domain.ServiceToken
|
||||
if err := rows.Scan(&item.ID, &item.Name, &item.TokenPrefix, &item.AppID, &item.EnvironmentID, &item.Permissions, &item.ExpiresAt, &item.RevokedAt, &item.LastUsedAt, &item.CreatedAt, &item.CreatedBy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) CreateServiceToken(ctx context.Context, input domain.ServiceToken, tokenHash string) (domain.ServiceToken, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return domain.ServiceToken{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO service_tokens(name, token_hash, token_prefix, app_id, env_id, permissions, expires_at, created_by)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8)
|
||||
RETURNING id, name, token_prefix, app_id, env_id, permissions, expires_at, revoked_at, last_used_at, created_at, created_by`,
|
||||
input.Name, tokenHash, input.TokenPrefix, input.AppID, input.EnvironmentID, input.Permissions, input.ExpiresAt, input.CreatedBy,
|
||||
).Scan(&input.ID, &input.Name, &input.TokenPrefix, &input.AppID, &input.EnvironmentID, &input.Permissions, &input.ExpiresAt, &input.RevokedAt, &input.LastUsedAt, &input.CreatedAt, &input.CreatedBy)
|
||||
if err != nil {
|
||||
return domain.ServiceToken{}, classify(err)
|
||||
}
|
||||
if err := insertAudit(ctx, tx, actor(input.CreatedBy), "create", "service_token", input.ID, map[string]any{
|
||||
"name": input.Name, "appId": input.AppID, "envId": input.EnvironmentID, "permissions": input.Permissions, "expiresAt": input.ExpiresAt,
|
||||
}); err != nil {
|
||||
return domain.ServiceToken{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.ServiceToken{}, err
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func (s *Store) FindServiceTokenByHash(ctx context.Context, tokenHash string) (domain.ServiceTokenCredential, error) {
|
||||
var item domain.ServiceTokenCredential
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, name, token_hash, token_prefix, app_id, env_id, permissions, expires_at, revoked_at, last_used_at, created_at, created_by
|
||||
FROM service_tokens WHERE token_hash=$1`, tokenHash).
|
||||
Scan(&item.ID, &item.Name, &item.TokenHash, &item.TokenPrefix, &item.AppID, &item.EnvironmentID, &item.Permissions, &item.ExpiresAt, &item.RevokedAt, &item.LastUsedAt, &item.CreatedAt, &item.CreatedBy)
|
||||
return item, classify(err)
|
||||
}
|
||||
|
||||
func (s *Store) TouchServiceToken(ctx context.Context, id int64) error {
|
||||
command, err := s.pool.Exec(ctx, `UPDATE service_tokens SET last_used_at=now() WHERE id=$1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if command.RowsAffected() == 0 {
|
||||
return storepkg.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) RevokeServiceToken(ctx context.Context, id int64, updatedBy string) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck
|
||||
var name string
|
||||
var revokedAt *time.Time
|
||||
err = tx.QueryRow(ctx, `SELECT name, revoked_at FROM service_tokens WHERE id=$1 FOR UPDATE`, id).Scan(&name, &revokedAt)
|
||||
if err != nil {
|
||||
return classify(err)
|
||||
}
|
||||
if revokedAt != nil {
|
||||
return nil
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE service_tokens SET revoked_at=now() WHERE id=$1`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := insertAudit(ctx, tx, actor(updatedBy), "revoke", "service_token", id, map[string]any{"name": name}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) ResolveScope(ctx context.Context, envCode, appCode, namespaceName string) (int64, int64, int64, error) {
|
||||
var appID, namespaceID, envID int64
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
|
||||
@@ -2,7 +2,9 @@ package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
@@ -120,3 +122,98 @@ func TestUserLifecycleAndAuditIntegration(t *testing.T) {
|
||||
t.Fatalf("audit cursor did not advance: first=%#v next=%#v", page.Items, next.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceTokenIntegration(t *testing.T) {
|
||||
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not configured")
|
||||
}
|
||||
ctx := t.Context()
|
||||
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)
|
||||
appCode := "token-" + suffix
|
||||
envCode := "T" + suffix
|
||||
if len(envCode) > 32 {
|
||||
envCode = envCode[:32]
|
||||
}
|
||||
app, err := repository.CreateApplication(ctx, domain.Application{Code: appCode, Name: "Token Test", UpdatedBy: "root"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env, err := repository.CreateEnvironment(ctx, domain.Environment{Code: envCode, Name: "Token Test", UpdatedBy: "root"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = repository.pool.Exec(context.Background(), `DELETE FROM applications WHERE id=$1`, app.ID)
|
||||
_, _ = repository.pool.Exec(context.Background(), `DELETE FROM environments WHERE id=$1`, env.ID)
|
||||
}()
|
||||
|
||||
sum := sha256.Sum256([]byte(suffix))
|
||||
tokenHash := fmt.Sprintf("%x", sum[:])
|
||||
expiresAt := time.Now().UTC().Add(24 * time.Hour)
|
||||
created, err := repository.CreateServiceToken(ctx, domain.ServiceToken{
|
||||
Name: "venus-dev", TokenPrefix: "cc_pat_test1234", AppID: app.ID, EnvironmentID: env.ID,
|
||||
Permissions: []string{"config:read", "config:watch"}, ExpiresAt: &expiresAt, CreatedBy: "root",
|
||||
}, tokenHash)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
credential, err := repository.FindServiceTokenByHash(ctx, tokenHash)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if credential.ID != created.ID || credential.TokenHash != tokenHash || credential.AppID != app.ID || credential.EnvironmentID != env.ID || len(credential.Permissions) != 2 {
|
||||
t.Fatalf("unexpected stored credential: %#v", credential)
|
||||
}
|
||||
if err := repository.TouchServiceToken(ctx, created.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err := repository.ListServiceTokens(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var found *domain.ServiceToken
|
||||
for i := range items {
|
||||
if items[i].ID == created.ID {
|
||||
found = &items[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil || found.LastUsedAt == nil {
|
||||
t.Fatalf("last-used timestamp not persisted: %#v", found)
|
||||
}
|
||||
if err := repository.RevokeServiceToken(ctx, created.ID, "root"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
credential, err = repository.FindServiceTokenByHash(ctx, tokenHash)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if credential.RevokedAt == nil {
|
||||
t.Fatal("revocation timestamp was not persisted")
|
||||
}
|
||||
page, err := repository.ListAuditLogs(ctx, domain.AuditLogQuery{Actor: "root", TargetType: "service_token", Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var createFound, revokeFound bool
|
||||
for _, item := range page.Items {
|
||||
if item.TargetID == nil || *item.TargetID != created.ID {
|
||||
continue
|
||||
}
|
||||
createFound = createFound || item.Action == "create"
|
||||
revokeFound = revokeFound || item.Action == "revoke"
|
||||
}
|
||||
if !createFound || !revokeFound {
|
||||
t.Fatalf("service token audit records missing: %#v", page.Items)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,12 @@ type Store interface {
|
||||
GetUserAppRole(context.Context, int64, int64) (string, error)
|
||||
ListUserAppRoles(context.Context, int64) ([]domain.UserAppRole, error)
|
||||
|
||||
ListServiceTokens(context.Context) ([]domain.ServiceToken, error)
|
||||
CreateServiceToken(context.Context, domain.ServiceToken, string) (domain.ServiceToken, error)
|
||||
FindServiceTokenByHash(context.Context, string) (domain.ServiceTokenCredential, error)
|
||||
TouchServiceToken(context.Context, int64) error
|
||||
RevokeServiceToken(context.Context, int64, string) error
|
||||
|
||||
ResolveScope(context.Context, string, string, string) (int64, int64, int64, error)
|
||||
ListGrayRules(context.Context, int64, int64, int64) ([]domain.GrayRule, error)
|
||||
GetGrayRule(context.Context, int64) (domain.GrayRule, error)
|
||||
|
||||
@@ -90,6 +90,20 @@ function auditLog(item) {
|
||||
};
|
||||
}
|
||||
|
||||
function serviceToken(item) {
|
||||
return {
|
||||
...item,
|
||||
id: normalizeId(item.id),
|
||||
appId: normalizeId(item.appId),
|
||||
envId: normalizeId(item.envId),
|
||||
expiresAtRaw: item.expiresAt || '',
|
||||
expiresAt: localTime(item.expiresAt),
|
||||
revokedAt: localTime(item.revokedAt),
|
||||
lastUsedAt: localTime(item.lastUsedAt),
|
||||
createdAt: localTime(item.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const token = accessToken();
|
||||
const response = await fetch(`${API_ROOT}${path}`, {
|
||||
@@ -214,6 +228,14 @@ export const api = {
|
||||
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' }),
|
||||
listServiceTokens: () => request('/v1/service-tokens').then((items) => items.map(serviceToken)),
|
||||
createServiceToken: (input) => request('/v1/service-tokens', json('POST', {
|
||||
...input,
|
||||
appId: Number(input.appId),
|
||||
envId: Number(input.envId),
|
||||
expiresInDays: Number(input.expiresInDays),
|
||||
})).then((result) => ({ ...result, item: serviceToken(result.item) })),
|
||||
revokeServiceToken: (id) => request(`/v1/service-tokens/${id}`, { method: 'DELETE' }),
|
||||
async listAuditLogs(filters = {}) {
|
||||
const query = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
|
||||
Reference in New Issue
Block a user