feat(access): гранулярные доступы (access_grants)
Some checks failed
CI / test (push) Failing after 10s
Build and Deploy / build-and-deploy (push) Successful in 27s

AccessGrantRepository:
- CRUD (List/Create/Get/Delete) с UPSERT на (resource, subject) для
  идемпотентности повторных grant'ов.
- ResolveVisibleResourceIDs(viewer ViewerContext) — для данного юзера
  возвращает DISTINCT set resource_id'шников, выданных через любой из
  subject_type: 'public' OR 'user'==viewer OR 'role'∈viewer.RoleIDs OR
  'department'==viewer.DepartmentID OR 'position'==viewer.PositionID.
  ViewerContext собирается из X-User-Roles/Department-Id/Position-Id
  headers'ов (portal-gateway прокидывает после JWT-валидации).

AccessGrantHandler:
- GET    /access/{resourceType}/{resourceId} — list (owner-only).
- POST   /access/{resourceType}/{resourceId} — выдать (UPSERT).
- DELETE /access/{resourceType}/{resourceId}/grants/{grantId} —
  отозвать. resourceType/Id дублируются в URL'е для cross-check'а
  чтобы owner ресурса A не мог удалить grant ресурса B по grantId.

Интеграция в List'ах:
- TestHandler.List: ?mine=true работает как было; без mine видны
  published + дозалив unpublished, выданных через access_grants.
- CourseHandler.List: то же поведение зеркально.
Семантика union'а: «published all + grant-only». Это backward-compat
(старые published продолжают быть видны всем), при этом HR может
явно выдать draft-ресурс конкретному юзеру/роли без публикации.

helpers.go: viewerContextFromHeaders — парсит X-User-Roles (CSV),
X-User-Department-Id, X-User-Position-Id; невалидные/пустые → default.

Wire-up: accessRepo внедрён в Test/Course handler'ы; accessH
зарегистрирован вместо предыдущей 501-заглушки.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilya
2026-05-26 01:17:42 +03:00
parent d773999296
commit 89abcc1718
6 changed files with 468 additions and 26 deletions

View File

@@ -12,20 +12,29 @@ import (
)
type TestHandler struct {
repo *repository.TestRepository
repo *repository.TestRepository
accessRepo *repository.AccessGrantRepository
}
func NewTestHandler(repo *repository.TestRepository) *TestHandler {
return &TestHandler{repo: repo}
func NewTestHandler(repo *repository.TestRepository, accessRepo *repository.AccessGrantRepository) *TestHandler {
return &TestHandler{repo: repo, accessRepo: accessRepo}
}
// List — GET /tests. Параметры:
// ?mine=true — только мои (owner_user_id = X-User-Id);
// без mine — published тесты (для прохождения).
// без mine — published тесты, ВИДИМЫЕ юзеру через access_grants.
//
// MVP: ещё не подключён access_grants-фильтр; до этого момента «published»
// = «всем видно». Следующая итерация: handler через AccessRepository
// получит visibleIDs и передаст в repo.List.
// Видимость:
// - owner всегда видит свои (мимо access_grants);
// - не-owner видит published + (resolvedVisibleIDs ИЛИ public-grant).
// MVP: published без явных grants считается «видимым всем» — этот режим
// сохранён как fallback, чтобы не сломать существующие тесты. Когда фронт
// начнёт массово выдавать гранты на каждый тест, перейдём на строгий
// режим (если есть хотя бы один не-public grant, видны только grant'ы).
//
// На now: visibleIDs объединяет (own published) (через access_grants).
// Если у юзера 0 grant'ов И тест опубликован — он его всё равно видит
// (publicness == default).
func (h *TestHandler) List(w http.ResponseWriter, r *http.Request) {
uid, ok := userIDFromHeader(r)
if !ok {
@@ -33,15 +42,53 @@ func (h *TestHandler) List(w http.ResponseWriter, r *http.Request) {
return
}
mine := r.URL.Query().Get("mine") == "true"
var ownerFilter *uuid.UUID
if mine {
ownerFilter = &uid
tests, err := h.repo.List(r.Context(), &uid, false, nil)
if err != nil {
writeRepoError(w, r, err, "list tests")
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": tests})
return
}
tests, err := h.repo.List(r.Context(), ownerFilter, !mine, nil)
// Не-owner: published + access_grants. Передаём visibleIDs=nil (без
// фильтра по id) если access_grants пуст — тогда видны все published.
// Если есть хоть один grant — берём union (id IN grants) OR
// (is_published AND owner = me); но репо API сейчас принимает один
// visibleIDs-набор. Расширим в next iteration; в MVP даём оба пути:
// просто все published, плюс отдельно — все из grant'ов даже если
// они не published.
viewer := viewerContextFromHeaders(r, uid)
grantIDs, err := h.accessRepo.ResolveVisibleResourceIDs(r.Context(), "test", viewer)
if err != nil {
writeRepoError(w, r, err, "resolve access")
return
}
tests, err := h.repo.List(r.Context(), nil, true, nil)
if err != nil {
writeRepoError(w, r, err, "list tests")
return
}
// Дозаливаем тесты которые выданы через grants но не опубликованы.
if len(grantIDs) > 0 {
extra, err := h.repo.List(r.Context(), nil, false, grantIDs)
if err != nil {
writeRepoError(w, r, err, "list granted tests")
return
}
seen := map[string]struct{}{}
for _, t := range tests {
seen[t.ID.String()] = struct{}{}
}
for _, t := range extra {
if _, ok := seen[t.ID.String()]; ok {
continue
}
// Black-box: уже опубликованные через published-ветку не дублируются;
// добавляем только новые (выданные через grant, но НЕ published).
tests = append(tests, t)
}
}
writeJSON(w, http.StatusOK, map[string]any{"items": tests})
}