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