feat(permissions): B8 — временные права (expires_at) с авто-снятием

Миграция 053: user_permissions.expires_at (NULL = бессрочно). Резолвер isEnabled
+ /me + /users/:id игнорируют просроченные оверрайды (наследуют роль); seedDefaults
чистит просроченные строки. setUserPermission принимает days → выдаёт право на
срок (datetime('now','+N days')). API отдаёт expiresAt. Клиент: setUserPermission(...,days).
В модалке прав пользователя — бейдж «до ДАТА» + кнопка «врем.» (выдать на N дней).
Тест: срок хранится/отдаётся, просроченное игнорируется и вычищается. Backend pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-03 14:43:06 +03:00
parent 8b495f1508
commit a250d15f9a
6 changed files with 83 additions and 14 deletions
@@ -19,6 +19,9 @@ function seedDefaults() {
for (const p of ALL_PERMISSIONS) upsert.run(p.role, p.key, p.default);
});
run();
// Чистим просроченные временные оверрайды (B8). Резолвер их и так игнорирует —
// здесь убираем «мусор» из таблицы.
try { db.prepare("DELETE FROM user_permissions WHERE expires_at IS NOT NULL AND expires_at <= datetime('now')").run(); } catch (_e) { /* колонки может не быть на старом инстансе */ }
}
/* ── GET /api/permissions ─────────────────────────────────────────────── */
@@ -64,7 +67,7 @@ function getMyPermissions(req, res) {
for (const r of roleRows) roleMap[r.permission] = r.enabled === 1;
const userRows = db.prepare(
'SELECT permission, enabled FROM user_permissions WHERE user_id = ?'
"SELECT permission, enabled FROM user_permissions WHERE user_id = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
).all(uid);
const userMap = {};
for (const r of userRows) userMap[r.permission] = r.enabled === 1;
@@ -93,12 +96,12 @@ function getUserPermissions(req, res) {
const roleMap = {};
for (const r of roleRows) roleMap[r.permission] = r.enabled === 1;
// user-level overrides
// user-level overrides (просроченные временные не учитываем)
const userRows = db.prepare(
'SELECT permission, enabled FROM user_permissions WHERE user_id = ?'
"SELECT permission, enabled, expires_at FROM user_permissions WHERE user_id = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
).all(uid);
const userMap = {};
for (const r of userRows) userMap[r.permission] = r.enabled === 1;
const userMap = {}, userExp = {};
for (const r of userRows) { userMap[r.permission] = r.enabled === 1; if (r.expires_at) userExp[r.permission] = r.expires_at; }
const defs = ALL_PERMISSIONS.filter(p => p.role === target.role);
const base = {};
@@ -110,6 +113,7 @@ function getUserPermissions(req, res) {
requires: d.requires || [],
roleVal: roleMap[d.key] ?? d.default, // effective role-level value
userVal: userMap[d.key], // undefined = no override
expiresAt: userExp[d.key] || null, // срок временного оверрайда (UTC) или null
effective: base[d.key] && (d.requires || []).every(r => !!base[r]),
}));
@@ -124,17 +128,25 @@ function setUserPermission(req, res) {
if (!target) return res.status(404).json({ error: 'User not found' });
if (!ALL_PERMISSIONS.find(p => p.key === permission && p.role === target.role))
return res.status(400).json({ error: 'Unknown permission for this role' });
const days = Number(req.body.days);
const hasExp = Number.isInteger(days) && days > 0; // временный оверрайд на N дней
db.transaction(() => {
db.prepare(
'INSERT OR REPLACE INTO user_permissions (user_id, permission, enabled) VALUES (?, ?, ?)'
).run(uid, permission, enabled ? 1 : 0);
// Invalidate existing JWT for this user immediately
if (hasExp) {
db.prepare(
"INSERT OR REPLACE INTO user_permissions (user_id, permission, enabled, expires_at) VALUES (?, ?, ?, datetime('now', ?))"
).run(uid, permission, enabled ? 1 : 0, '+' + days + ' days');
} else {
db.prepare(
'INSERT OR REPLACE INTO user_permissions (user_id, permission, enabled, expires_at) VALUES (?, ?, ?, NULL)'
).run(uid, permission, enabled ? 1 : 0);
}
// Invalidate existing JWT for this user immediately (точечно одному пользователю)
db.prepare(
'UPDATE users SET token_version = token_version + 1 WHERE id = ?'
).run(uid);
})();
audit(req, 'permission.user_set', `user:${uid}/${permission}`, `enabled=${enabled ? 1 : 0}`);
res.json({ ok: true });
audit(req, 'permission.user_set', `user:${uid}/${permission}`, `enabled=${enabled ? 1 : 0}${hasExp ? ` exp=+${days}d` : ''}`);
res.json({ ok: true, expires_in_days: hasExp ? days : null });
}
/* ── DELETE /api/permissions/users/:id/reset (single or all) ─────────── */