Files
learning/internal/repository/access_grant.go
Ilya 89abcc1718
Some checks failed
CI / test (push) Failing after 10s
Build and Deploy / build-and-deploy (push) Successful in 27s
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>
2026-05-26 01:17:42 +03:00

181 lines
6.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}