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

@@ -0,0 +1,150 @@
package handler
import (
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"learning-service/internal/model"
"learning-service/internal/repository"
)
type AccessGrantHandler struct {
repo *repository.AccessGrantRepository
testRepo *repository.TestRepository
courseRepo *repository.CourseRepository
}
func NewAccessGrantHandler(
repo *repository.AccessGrantRepository,
testRepo *repository.TestRepository,
courseRepo *repository.CourseRepository,
) *AccessGrantHandler {
return &AccessGrantHandler{repo: repo, testRepo: testRepo, courseRepo: courseRepo}
}
// authorizeResourceOwner — проверка прав на управление access_grants:
// только owner ресурса (или admin через bypass на портале). Owner
// автоматически имеет доступ ко всему, гранты для него не нужны —
// они только для «других».
func (h *AccessGrantHandler) authorizeResourceOwner(w http.ResponseWriter, r *http.Request, resourceType string, resourceID uuid.UUID) (uuid.UUID, bool) {
uid, ok := userIDFromHeader(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return uuid.Nil, false
}
var owner uuid.UUID
switch resourceType {
case "test":
t, err := h.testRepo.Get(r.Context(), resourceID)
if err != nil {
writeRepoError(w, r, err, "get test")
return uuid.Nil, false
}
owner = t.OwnerUserID
case "course":
c, err := h.courseRepo.Get(r.Context(), resourceID)
if err != nil {
writeRepoError(w, r, err, "get course")
return uuid.Nil, false
}
owner = c.OwnerUserID
default:
writeError(w, http.StatusBadRequest, "resource_type must be test|course")
return uuid.Nil, false
}
if owner != uid {
writeError(w, http.StatusForbidden, "only owner can manage access")
return uuid.Nil, false
}
return uid, true
}
// List — GET /access/{resourceType}/{resourceId}.
func (h *AccessGrantHandler) List(w http.ResponseWriter, r *http.Request) {
resourceType := strings.ToLower(chi.URLParam(r, "resourceType"))
resourceID, err := parseUUID(chi.URLParam(r, "resourceId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid resource id")
return
}
if _, ok := h.authorizeResourceOwner(w, r, resourceType, resourceID); !ok {
return
}
items, err := h.repo.List(r.Context(), resourceType, resourceID)
if err != nil {
writeRepoError(w, r, err, "list access grants")
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
// Create — POST /access/{resourceType}/{resourceId} {subject_type, subject_id, can_manage}.
func (h *AccessGrantHandler) Create(w http.ResponseWriter, r *http.Request) {
resourceType := strings.ToLower(chi.URLParam(r, "resourceType"))
resourceID, err := parseUUID(chi.URLParam(r, "resourceId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid resource id")
return
}
uid, ok := h.authorizeResourceOwner(w, r, resourceType, resourceID)
if !ok {
return
}
var req model.CreateAccessGrantRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid body")
return
}
if req.SubjectType != "user" && req.SubjectType != "role" &&
req.SubjectType != "department" && req.SubjectType != "position" &&
req.SubjectType != "public" {
writeError(w, http.StatusBadRequest, "subject_type must be user|role|department|position|public")
return
}
g, err := h.repo.Create(r.Context(), resourceType, resourceID, req, uid)
if err != nil {
writeRepoError(w, r, err, "create access grant")
return
}
writeJSON(w, http.StatusCreated, g)
}
// Delete — DELETE /access/{resourceType}/{resourceId}/grants/{grantId}.
// resourceType/resourceId дублируются в URL'е для удобства проверки прав
// (handler читает их из path-params, не из body).
func (h *AccessGrantHandler) Delete(w http.ResponseWriter, r *http.Request) {
resourceType := strings.ToLower(chi.URLParam(r, "resourceType"))
resourceID, err := parseUUID(chi.URLParam(r, "resourceId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid resource id")
return
}
grantID, err := parseUUID(chi.URLParam(r, "grantId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid grant id")
return
}
if _, ok := h.authorizeResourceOwner(w, r, resourceType, resourceID); !ok {
return
}
// Дополнительно проверим, что grant действительно принадлежит этому
// ресурсу — иначе owner ресурса A мог бы удалить грант ресурса B
// просто подсунув grantId.
g, err := h.repo.Get(r.Context(), grantID)
if err != nil {
writeRepoError(w, r, err, "get grant")
return
}
if g.ResourceType != resourceType || g.ResourceID != resourceID {
writeError(w, http.StatusBadRequest, "grant does not belong to this resource")
return
}
if err := h.repo.Delete(r.Context(), grantID); err != nil {
writeRepoError(w, r, err, "delete grant")
return
}
w.WriteHeader(http.StatusNoContent)
}