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:
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user