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>
151 lines
5.0 KiB
Go
151 lines
5.0 KiB
Go
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)
|
||
}
|