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>
This commit is contained in:
150
internal/handler/access_grant.go
Normal file
150
internal/handler/access_grant.go
Normal 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)
|
||||
}
|
||||
@@ -5,26 +5,24 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"learning-service/internal/model"
|
||||
"learning-service/internal/repository"
|
||||
)
|
||||
|
||||
type CourseHandler struct {
|
||||
repo *repository.CourseRepository
|
||||
repo *repository.CourseRepository
|
||||
accessRepo *repository.AccessGrantRepository
|
||||
}
|
||||
|
||||
func NewCourseHandler(repo *repository.CourseRepository) *CourseHandler {
|
||||
return &CourseHandler{repo: repo}
|
||||
func NewCourseHandler(repo *repository.CourseRepository, accessRepo *repository.AccessGrantRepository) *CourseHandler {
|
||||
return &CourseHandler{repo: repo, accessRepo: accessRepo}
|
||||
}
|
||||
|
||||
// List — GET /courses?mine=true. Поведение зеркально TestHandler.List:
|
||||
//
|
||||
// mine=true → только мои (owner_user_id = X-User-Id);
|
||||
// без mine → только опубликованные.
|
||||
//
|
||||
// В будущей итерации access_grants добавят visibleIDs-фильтр.
|
||||
// List — GET /courses?mine=true. Зеркально TestHandler.List:
|
||||
// mine=true → только мои;
|
||||
// без mine → published + видимые через access_grants.
|
||||
// Семантика union'а: published все + дозалив unpublished, выданных через grant.
|
||||
func (h *CourseHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
uid, ok := userIDFromHeader(r)
|
||||
if !ok {
|
||||
@@ -32,15 +30,43 @@ func (h *CourseHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
mine := r.URL.Query().Get("mine") == "true"
|
||||
var ownerFilter *uuid.UUID
|
||||
if mine {
|
||||
ownerFilter = &uid
|
||||
items, err := h.repo.List(r.Context(), &uid, false, nil)
|
||||
if err != nil {
|
||||
writeRepoError(w, r, err, "list courses")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
return
|
||||
}
|
||||
items, err := h.repo.List(r.Context(), ownerFilter, !mine, nil)
|
||||
viewer := viewerContextFromHeaders(r, uid)
|
||||
grantIDs, err := h.accessRepo.ResolveVisibleResourceIDs(r.Context(), "course", viewer)
|
||||
if err != nil {
|
||||
writeRepoError(w, r, err, "resolve access")
|
||||
return
|
||||
}
|
||||
items, err := h.repo.List(r.Context(), nil, true, nil)
|
||||
if err != nil {
|
||||
writeRepoError(w, r, err, "list courses")
|
||||
return
|
||||
}
|
||||
if len(grantIDs) > 0 {
|
||||
extra, err := h.repo.List(r.Context(), nil, false, grantIDs)
|
||||
if err != nil {
|
||||
writeRepoError(w, r, err, "list granted courses")
|
||||
return
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
for _, c := range items {
|
||||
seen[c.ID.String()] = struct{}{}
|
||||
}
|
||||
for _, c := range extra {
|
||||
if _, ok := seen[c.ID.String()]; ok {
|
||||
continue
|
||||
}
|
||||
items = append(items, c)
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
@@ -61,3 +62,34 @@ func userIDFromHeader(r *http.Request) (uuid.UUID, bool) {
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// viewerContextFromHeaders — собирает ViewerContext из portal-gateway-
|
||||
// прокинутых headers'ов. Пустые поля = соответствующие access_grants
|
||||
// не сматчатся (что корректно: «у меня нет роли X» → ресурсы выданные
|
||||
// роли X мне не видны).
|
||||
func viewerContextFromHeaders(r *http.Request, uid uuid.UUID) repository.ViewerContext {
|
||||
ctx := repository.ViewerContext{UserID: uid}
|
||||
// X-User-Roles: CSV ролевых UUID'ов.
|
||||
if v := r.Header.Get("X-User-Roles"); v != "" {
|
||||
for _, raw := range strings.Split(v, ",") {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
if id, err := uuid.Parse(raw); err == nil {
|
||||
ctx.RoleIDs = append(ctx.RoleIDs, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if v := r.Header.Get("X-User-Department-Id"); v != "" {
|
||||
if id, err := uuid.Parse(strings.TrimSpace(v)); err == nil {
|
||||
ctx.DepartmentID = &id
|
||||
}
|
||||
}
|
||||
if v := r.Header.Get("X-User-Position-Id"); v != "" {
|
||||
if id, err := uuid.Parse(strings.TrimSpace(v)); err == nil {
|
||||
ctx.PositionID = &id
|
||||
}
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user