From a250d15f9af5fb7889ddc07af52f76225f3f27d7 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Wed, 3 Jun 2026 14:43:06 +0300 Subject: [PATCH] =?UTF-8?q?feat(permissions):=20B8=20=E2=80=94=20=D0=B2?= =?UTF-8?q?=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=B0=20(expires=5Fat)=20=D1=81=20=D0=B0=D0=B2?= =?UTF-8?q?=D1=82=D0=BE-=D1=81=D0=BD=D1=8F=D1=82=D0=B8=D0=B5=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Миграция 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) --- .../src/controllers/permissionsController.js | 34 +++++++++++++------ .../migrations/053_user_permission_expiry.sql | 6 ++++ backend/src/middleware/auth.js | 3 +- backend/tests/permissions.test.js | 26 ++++++++++++++ frontend/js/admin/sections/users.js | 26 +++++++++++++- js/api.js | 2 +- 6 files changed, 83 insertions(+), 14 deletions(-) create mode 100644 backend/src/db/migrations/053_user_permission_expiry.sql diff --git a/backend/src/controllers/permissionsController.js b/backend/src/controllers/permissionsController.js index 646f91d..323f7d7 100644 --- a/backend/src/controllers/permissionsController.js +++ b/backend/src/controllers/permissionsController.js @@ -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) ─────────── */ diff --git a/backend/src/db/migrations/053_user_permission_expiry.sql b/backend/src/db/migrations/053_user_permission_expiry.sql new file mode 100644 index 0000000..0ea76bd --- /dev/null +++ b/backend/src/db/migrations/053_user_permission_expiry.sql @@ -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; diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index df69919..7a2acc2 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -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( diff --git a/backend/tests/permissions.test.js b/backend/tests/permissions.test.js index 7681e26..3ac69df 100644 --- a/backend/tests/permissions.test.js +++ b/backend/tests/permissions.test.js @@ -274,4 +274,30 @@ describe('Permissions', () => { const badP = await inject('POST', `/api/permissions/class/${cid}/preset`, { preset: 'nope' }, adminToken); assert.equal(badP.status, 400); }); + + // ── B8: временные права (expires_at) ─────────────────────────────────────── + it('B8: временный оверрайд хранит срок, отдаётся, просроченный игнорируется и чистится', async () => { + const r = await inject('POST', `/api/permissions/users/${studentUser.userId}`, + { permission: 'shop.purchase', enabled: false, days: 7 }, adminToken); + assert.equal(r.status, 200); + assert.equal(r.body.expires_in_days, 7); + const row = db.prepare('SELECT enabled, expires_at FROM user_permissions WHERE user_id=? AND permission=?') + .get(studentUser.userId, 'shop.purchase'); + assert.ok(row && row.enabled === 0 && row.expires_at, 'оверрайд с expires_at сохранён'); + + const view = await inject('GET', `/api/permissions/users/${studentUser.userId}`, null, adminToken); + const sp = view.body.permissions.find(p => p.key === 'shop.purchase'); + assert.equal(sp.userVal, false, 'активный временный оверрайд виден'); + assert.ok(sp.expiresAt, 'expiresAt отдаётся в API'); + + // имитируем просрочку + db.prepare("UPDATE user_permissions SET expires_at = datetime('now','-1 day') WHERE user_id=? AND permission=?") + .run(studentUser.userId, 'shop.purchase'); + const view2 = await inject('GET', `/api/permissions/users/${studentUser.userId}`, null, adminToken); + const sp2 = view2.body.permissions.find(p => p.key === 'shop.purchase'); + assert.equal(sp2.userVal, undefined, 'просроченный оверрайд не учитывается (наследует роль)'); + const left = db.prepare("SELECT COUNT(*) n FROM user_permissions WHERE user_id=? AND permission=?") + .get(studentUser.userId, 'shop.purchase').n; + assert.equal(left, 0, 'просроченная строка вычищена seedDefaults'); + }); }); diff --git a/frontend/js/admin/sections/users.js b/frontend/js/admin/sections/users.js index a5d454f..c18a300 100644 --- a/frontend/js/admin/sections/users.js +++ b/frontend/js/admin/sections/users.js @@ -364,6 +364,12 @@ const badge = hasOverride ? `Индивидуально` : `По роли`; + const expBadge = (hasOverride && p.expiresAt) + ? `до ${esc(p.expiresAt.slice(0, 10))}` + : ''; + const tempBtn = ``; const resetBtn = hasOverride ? `