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:
@@ -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) ─────────── */
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 053_user_permission_expiry.sql
|
||||
-- Временные права (B8): личный оверрайд может иметь срок действия. По истечении
|
||||
-- срока он перестаёт учитываться (резолвер фильтрует по expires_at) и периодически
|
||||
-- удаляется. NULL = бессрочно (как и было). Время в UTC ('YYYY-MM-DD HH:MM:SS').
|
||||
|
||||
ALTER TABLE user_permissions ADD COLUMN expires_at TEXT;
|
||||
@@ -44,8 +44,9 @@ function requireRole(...roles) {
|
||||
|
||||
/* ── Разрешено ли ОДНО право: user override → role override → дефолт реестра ── */
|
||||
function isEnabled(uid, role, key) {
|
||||
// Просроченный временный оверрайд (expires_at в прошлом) игнорируем — наследуем роль.
|
||||
const userRow = db.prepare(
|
||||
'SELECT enabled FROM user_permissions WHERE user_id = ? AND permission = ?'
|
||||
"SELECT enabled FROM user_permissions WHERE user_id = ? AND permission = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
|
||||
).get(uid, key);
|
||||
if (userRow !== undefined) return userRow.enabled === 1;
|
||||
const roleRow = db.prepare(
|
||||
|
||||
Reference in New Issue
Block a user