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

@@ -71,13 +71,15 @@ func main() {
courseRepo := repository.NewCourseRepository(pool)
lessonRepo := repository.NewLessonRepository(pool)
publicTokenRepo := repository.NewPublicTokenRepository(pool)
accessRepo := repository.NewAccessGrantRepository(pool)
healthH := handler.NewHealthHandler(pool)
testH := handler.NewTestHandler(testRepo)
testH := handler.NewTestHandler(testRepo, accessRepo)
attemptH := handler.NewAttemptHandler(attemptRepo, testRepo)
courseH := handler.NewCourseHandler(courseRepo)
courseH := handler.NewCourseHandler(courseRepo, accessRepo)
lessonH := handler.NewLessonHandler(lessonRepo, courseRepo, store)
publicTokenH := handler.NewPublicTokenHandler(publicTokenRepo, testRepo, courseRepo, attemptRepo)
accessH := handler.NewAccessGrantHandler(accessRepo, testRepo, courseRepo)
r := chi.NewRouter()
r.Use(chimw.RequestID)
@@ -137,7 +139,12 @@ func main() {
r.Post("/lessons/{id}/video", lessonH.UploadVideo)
r.Get("/lessons/{id}/video/stream", lessonH.StreamVideo)
r.Delete("/lessons/{id}/video", lessonH.DeleteVideo)
r.HandleFunc("/access/{resourceType}/{resourceId}", notImplemented)
// Access grants — гранулярные доступы (user/role/department/position/public).
// Управляет владелец ресурса; SubjectIDs матчатся по X-User-Roles/
// Department/Position-headers'ам, прокидываемым portal-gateway'ом.
r.Get("/access/{resourceType}/{resourceId}", accessH.List)
r.Post("/access/{resourceType}/{resourceId}", accessH.Create)
r.Delete("/access/{resourceType}/{resourceId}/grants/{grantId}", accessH.Delete)
// Public tokens — HR-side: создать ссылку для кандидата, посмотреть
// список, отозвать. Сам прохождение тестa по токену — в /public ниже.

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)
}

View File

@@ -5,7 +5,6 @@ import (
"strings"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"learning-service/internal/model"
"learning-service/internal/repository"
@@ -13,18 +12,17 @@ import (
type CourseHandler struct {
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,16 +30,44 @@ 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(), ownerFilter, !mine, nil)
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
}
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})
}
func (h *CourseHandler) Get(w http.ResponseWriter, r *http.Request) {

View File

@@ -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
}

View File

@@ -13,19 +13,28 @@ import (
type TestHandler struct {
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,16 +42,54 @@ 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(), ownerFilter, !mine, nil)
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
}
// Не-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})
}
func (h *TestHandler) Get(w http.ResponseWriter, r *http.Request) {

View File

@@ -0,0 +1,180 @@
package repository
import (
"context"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"learning-service/internal/model"
)
type AccessGrantRepository struct {
pool *pgxpool.Pool
}
func NewAccessGrantRepository(pool *pgxpool.Pool) *AccessGrantRepository {
return &AccessGrantRepository{pool: pool}
}
const grantCols = `
id, resource_type, resource_id, subject_type, subject_id,
can_manage, granted_by, created_at
`
func scanGrant(scan func(...any) error) (*model.AccessGrant, error) {
var g model.AccessGrant
if err := scan(
&g.ID, &g.ResourceType, &g.ResourceID, &g.SubjectType, &g.SubjectID,
&g.CanManage, &g.GrantedBy, &g.CreatedAt,
); err != nil {
return nil, err
}
return &g, nil
}
// List — все гранты на ресурс. Для HR-UI «кому я разрешил».
func (r *AccessGrantRepository) List(ctx context.Context, resourceType string, resourceID uuid.UUID) ([]model.AccessGrant, error) {
rows, err := r.pool.Query(ctx,
`SELECT `+grantCols+` FROM access_grants
WHERE resource_type = $1 AND resource_id = $2
ORDER BY created_at DESC`,
resourceType, resourceID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []model.AccessGrant{}
for rows.Next() {
g, err := scanGrant(rows.Scan)
if err != nil {
return nil, err
}
out = append(out, *g)
}
return out, rows.Err()
}
// Create — выдать доступ. ON CONFLICT (resource_type, resource_id,
// subject_type, subject_id) DO NOTHING — повторная выдача того же гранта
// идемпотентна. can_manage обновляется через отдельный Update.
func (r *AccessGrantRepository) Create(ctx context.Context, resourceType string, resourceID uuid.UUID, req model.CreateAccessGrantRequest, grantedBy uuid.UUID) (*model.AccessGrant, error) {
if req.SubjectType == "public" && req.SubjectID != nil {
return nil, fmt.Errorf("subject_id must be NULL for public grants")
}
if req.SubjectType != "public" && req.SubjectID == nil {
return nil, fmt.Errorf("subject_id is required for %s grants", req.SubjectType)
}
var id uuid.UUID
err := r.pool.QueryRow(ctx, `
INSERT INTO access_grants (
resource_type, resource_id, subject_type, subject_id, can_manage, granted_by
) VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (resource_type, resource_id, subject_type, subject_id) DO UPDATE
SET can_manage = EXCLUDED.can_manage
RETURNING id`,
resourceType, resourceID, req.SubjectType, req.SubjectID,
req.CanManage, grantedBy,
).Scan(&id)
if err != nil {
return nil, err
}
return r.Get(ctx, id)
}
func (r *AccessGrantRepository) Get(ctx context.Context, id uuid.UUID) (*model.AccessGrant, error) {
g, err := scanGrant(r.pool.QueryRow(ctx,
`SELECT `+grantCols+` FROM access_grants WHERE id = $1`, id).Scan)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return g, nil
}
// Delete — отзыв доступа. Сам объект remove'ится из БД.
func (r *AccessGrantRepository) Delete(ctx context.Context, id uuid.UUID) error {
tag, err := r.pool.Exec(ctx, `DELETE FROM access_grants WHERE id = $1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// ViewerContext — субъекты, которые «представляет» конкретный юзер при
// проверке доступа. Заполняется handler'ом из X-User-* headers (portal-
// gateway знает permission/role/department/position юзера, прокидывает).
//
// UserID должен быть всегда; роли/dept/position — могут быть пустыми
// (тогда соответствующие grants не сматчатся, что корректно).
type ViewerContext struct {
UserID uuid.UUID
RoleIDs []uuid.UUID
DepartmentID *uuid.UUID
PositionID *uuid.UUID
}
// ResolveVisibleResourceIDs — для данного viewer'а возвращает множество
// resource_id'шников, которым он разрешён через access_grants.
// resource_type фильтрует ('test' | 'course').
//
// Логика SQL: WHERE (subject_type='public') OR
// (subject_type='user' AND subject_id = viewer_id) OR
// (subject_type='role' AND subject_id = ANY(viewer.roles)) OR
// (subject_type='department' AND subject_id = viewer.department) OR
// (subject_type='position' AND subject_id = viewer.position)
//
// Возвращается distinct set — один и тот же ресурс может быть выдан
// несколькими grants одновременно (например, и по роли, и по
// department'у); в результате он окажется один раз.
//
// Этот метод используется в Tests.List / Courses.List как visibleIDs-
// фильтр (см. repository.TestRepository.List signature).
func (r *AccessGrantRepository) ResolveVisibleResourceIDs(ctx context.Context, resourceType string, viewer ViewerContext) ([]uuid.UUID, error) {
// Собираем conds OR'ом. Empty viewer.RoleIDs пропускаем чтобы не
// генерить пустой ANY('{}') (он валиден, но бессмысленнен).
conds := []string{"subject_type = 'public'"}
args := []any{resourceType, viewer.UserID}
// $1 = resource_type, $2 = user_id, далее по индексу.
conds = append(conds, "(subject_type = 'user' AND subject_id = $2)")
if len(viewer.RoleIDs) > 0 {
args = append(args, viewer.RoleIDs)
conds = append(conds, fmt.Sprintf("(subject_type = 'role' AND subject_id = ANY($%d))", len(args)))
}
if viewer.DepartmentID != nil {
args = append(args, *viewer.DepartmentID)
conds = append(conds, fmt.Sprintf("(subject_type = 'department' AND subject_id = $%d)", len(args)))
}
if viewer.PositionID != nil {
args = append(args, *viewer.PositionID)
conds = append(conds, fmt.Sprintf("(subject_type = 'position' AND subject_id = $%d)", len(args)))
}
sql := fmt.Sprintf(`
SELECT DISTINCT resource_id
FROM access_grants
WHERE resource_type = $1
AND (%s)`, strings.Join(conds, " OR "))
rows, err := r.pool.Query(ctx, sql, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []uuid.UUID{}
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}