Files
learning/internal/handler/access_grant.go
Ilya 89abcc1718
Some checks failed
CI / test (push) Failing after 10s
Build and Deploy / build-and-deploy (push) Successful in 27s
feat(access): гранулярные доступы (access_grants)
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>
2026-05-26 01:17:42 +03:00

151 lines
5.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}