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

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