Rewrite identity and access management

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2025-12-03 19:23:15 +01:00
parent 4ed3f5a067
commit 74fc3b8cd1
201 changed files with 32895 additions and 23649 deletions

View File

@@ -21,10 +21,10 @@ import (
"maps"
"time"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
@@ -43,24 +43,8 @@ type (
}
Assets []*Asset
ErrAssetNotFound struct {
Identifier string
}
ErrAssetAlreadyExists struct {
message string
}
)
func (e ErrAssetNotFound) Error() string {
return fmt.Sprintf("asset not found: %q", e.Identifier)
}
func (e ErrAssetAlreadyExists) Error() string {
return e.message
}
func (a *Asset) CursorKey(field AssetOrderField) page.CursorKey {
switch field {
case AssetOrderFieldCreatedAt:
@@ -112,7 +96,7 @@ LIMIT 1;
asset, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Asset])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrAssetNotFound{Identifier: assetID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect asset: %w", err)
@@ -162,7 +146,7 @@ LIMIT 1;
asset, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Asset])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrAssetNotFound{Identifier: a.OwnerID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect asset: %w", err)

View File

@@ -21,10 +21,10 @@ import (
"maps"
"time"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
@@ -43,24 +43,8 @@ type (
}
Audits []*Audit
ErrAuditNotFound struct {
Identifier string
}
ErrAuditAlreadyExists struct {
message string
}
)
func (e ErrAuditNotFound) Error() string {
return fmt.Sprintf("audit not found: %q", e.Identifier)
}
func (e ErrAuditAlreadyExists) Error() string {
return e.message
}
func (a *Audit) CursorKey(field AuditOrderField) page.CursorKey {
switch field {
case AuditOrderFieldCreatedAt:
@@ -116,7 +100,7 @@ LIMIT 1;
audit, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Audit])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrAuditNotFound{Identifier: auditID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect audit: %w", err)
@@ -530,7 +514,7 @@ LIMIT 1;
audit, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Audit])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrAuditNotFound{Identifier: reportID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect audit: %w", err)

View File

@@ -44,24 +44,8 @@ type (
}
Controls []*Control
ErrControlNotFound struct {
Identifier string
}
ErrControlAlreadyExists struct {
message string
}
)
func (e ErrControlNotFound) Error() string {
return fmt.Sprintf("control not found: %q", e.Identifier)
}
func (e ErrControlAlreadyExists) Error() string {
return e.message
}
func (c Control) CursorKey(orderBy ControlOrderField) page.CursorKey {
switch orderBy {
case ControlOrderFieldCreatedAt:
@@ -661,7 +645,7 @@ LIMIT 1;
control, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Control])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrControlNotFound{Identifier: fmt.Sprintf("%s:%s", frameworkID, sectionTitle)}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect control: %w", err)
@@ -710,7 +694,7 @@ LIMIT 1;
control, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Control])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrControlNotFound{Identifier: controlID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect control: %w", err)
@@ -778,9 +762,7 @@ VALUES (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "controls_framework_ref_unique" {
return &ErrControlAlreadyExists{
message: fmt.Sprintf("control with framework_id %s and section_title %q already exists", c.FrameworkID, c.SectionTitle),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert control: %w", err)
@@ -848,9 +830,7 @@ WHERE %s
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "controls_framework_ref_unique" {
return &ErrControlAlreadyExists{
message: fmt.Sprintf("control with section_title %q already exists", c.SectionTitle),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot update control: %w", err)

View File

@@ -21,10 +21,10 @@ import (
"maps"
"time"
"go.probo.inc/probo/pkg/gid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
)
type (
@@ -37,17 +37,8 @@ type (
}
ControlDocuments []*ControlDocument
ErrControlDocumentMappingAlreadyExists struct {
ControlID gid.GID
DocumentID gid.GID
}
)
func (e ErrControlDocumentMappingAlreadyExists) Error() string {
return fmt.Sprintf("control %s is already mapped to document %s", e.ControlID, e.DocumentID)
}
func (cp ControlDocument) Insert(
ctx context.Context,
conn pg.Conn,
@@ -84,10 +75,7 @@ VALUES (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "controls_policies_pkey" {
return &ErrControlDocumentMappingAlreadyExists{
ControlID: cp.ControlID,
DocumentID: cp.DocumentID,
}
return ErrResourceAlreadyExists
}
}

View File

@@ -52,24 +52,8 @@ type (
}
CustomDomains []*CustomDomain
ErrCustomDomainNotFound struct {
Identifier string
}
ErrCustomDomainAlreadyExists struct {
message string
}
)
func (e ErrCustomDomainNotFound) Error() string {
return fmt.Sprintf("custom domain not found: %q", e.Identifier)
}
func (e ErrCustomDomainAlreadyExists) Error() string {
return e.message
}
func NewCustomDomain(tenantID gid.TenantID, domain string) *CustomDomain {
now := time.Now()
return &CustomDomain{
@@ -385,9 +369,7 @@ INSERT INTO custom_domains (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "custom_domains_domain_key" {
return &ErrCustomDomainAlreadyExists{
message: fmt.Sprintf("custom domain with domain %q already exists", cd.Domain),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert custom domain: %w", err)

View File

@@ -43,24 +43,8 @@ type (
}
Documents []*Document
ErrDocumentNotFound struct {
Identifier string
}
ErrDocumentAlreadyExists struct {
message string
}
)
func (e ErrDocumentNotFound) Error() string {
return fmt.Sprintf("document not found: %q", e.Identifier)
}
func (e ErrDocumentAlreadyExists) Error() string {
return e.message
}
func (p Document) CursorKey(orderBy DocumentOrderField) page.CursorKey {
switch orderBy {
case DocumentOrderFieldCreatedAt:
@@ -114,7 +98,7 @@ LIMIT 1;
document, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Document])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrDocumentNotFound{Identifier: documentID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect document: %w", err)
@@ -168,7 +152,7 @@ LIMIT 1;
document, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Document])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrDocumentNotFound{Identifier: documentID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect document: %w", err)

View File

@@ -46,32 +46,8 @@ type (
}
DocumentVersions []*DocumentVersion
ErrDocumentVersionNotFound struct {
Identifier string
}
ErrDocumentVersionAlreadyExists struct {
message string
}
ErrDocumentVersionNoChanges struct {
Message string
}
)
func (e ErrDocumentVersionNotFound) Error() string {
return fmt.Sprintf("document version not found: %q", e.Identifier)
}
func (e ErrDocumentVersionAlreadyExists) Error() string {
return e.message
}
func (e ErrDocumentVersionNoChanges) Error() string {
return e.Message
}
func (p *DocumentVersions) LoadByDocumentID(
ctx context.Context,
conn pg.Conn,
@@ -245,15 +221,8 @@ VALUES (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" {
if pgErr.ConstraintName == "document_versions_document_id_version_number_key" {
return &ErrDocumentVersionAlreadyExists{
message: fmt.Sprintf("document version with document_id %s and version_number %d already exists", p.DocumentID, p.VersionNumber),
}
}
if pgErr.ConstraintName == "document_one_draft_version_idx" {
return &ErrDocumentVersionAlreadyExists{
message: fmt.Sprintf("document %s already has a draft version", p.DocumentID),
}
if pgErr.ConstraintName == "document_versions_document_id_version_number_key" || pgErr.ConstraintName == "document_one_draft_version_idx" {
return ErrResourceAlreadyExists
}
}
}

View File

@@ -50,30 +50,7 @@ type (
}
DocumentVersionSignaturesWithPeople []*DocumentVersionSignatureWithPeople
ErrDocumentVersionSignatureNotFound struct {
Identifier string
}
ErrDocumentVersionSignatureAlreadyExists struct {
message string
}
ErrDocumentVersionSignatureAlreadySigned struct{}
)
func (e ErrDocumentVersionSignatureNotFound) Error() string {
return fmt.Sprintf("document version signature not found: %q", e.Identifier)
}
func (e ErrDocumentVersionSignatureAlreadyExists) Error() string {
return e.message
}
func (e ErrDocumentVersionSignatureAlreadySigned) Error() string {
return "document version already signed"
}
func (pvs DocumentVersionSignature) CursorKey(orderBy DocumentVersionSignatureOrderField) page.CursorKey {
switch orderBy {
case DocumentVersionSignatureOrderFieldCreatedAt:
@@ -225,9 +202,7 @@ INSERT INTO document_version_signatures (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "policy_version_signatures_policy_version_id_signed_by_key" {
return &ErrDocumentVersionSignatureAlreadyExists{
message: fmt.Sprintf("document version signature with document_version_id %s and signed_by %s already exists", pvs.DocumentVersionID, pvs.SignedBy),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert document version signature: %w", err)

11
pkg/coredata/errors.go Normal file
View File

@@ -0,0 +1,11 @@
package coredata
import (
"errors"
)
var (
ErrResourceNotFound = errors.New("resource not found")
ErrResourceAlreadyExists = errors.New("resource already exists")
ErrResourceInUse = errors.New("resource is in use")
)

View File

@@ -45,24 +45,8 @@ type (
}
Evidences []*Evidence
ErrEvidenceNotFound struct {
Identifier string
}
ErrEvidenceAlreadyExists struct {
message string
}
)
func (e ErrEvidenceNotFound) Error() string {
return fmt.Sprintf("evidence not found: %q", e.Identifier)
}
func (e ErrEvidenceAlreadyExists) Error() string {
return e.message
}
func (e Evidence) CursorKey(orderBy EvidenceOrderField) page.CursorKey {
switch orderBy {
case EvidenceOrderFieldCreatedAt:
@@ -192,9 +176,7 @@ VALUES (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "evidences_reference_id_key" {
return &ErrEvidenceAlreadyExists{
message: fmt.Sprintf("evidence with task_id %s and reference_id %q already exists", e.TaskID, e.ReferenceID),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert evidence: %w", err)

View File

@@ -0,0 +1,11 @@
package coredata
type (
ExpireReason string
)
const (
ExpireReasonIdleTimeout ExpireReason = "idle_timeout"
ExpireReasonRevoked ExpireReason = "revoked"
ExpireReasonClosed ExpireReason = "closed"
)

View File

@@ -42,24 +42,8 @@ type (
}
Files []*File
ErrFileNotFound struct {
Identifier string
}
ErrFileAlreadyExists struct {
message string
}
)
func (e ErrFileNotFound) Error() string {
return fmt.Sprintf("file not found: %q", e.Identifier)
}
func (e ErrFileAlreadyExists) Error() string {
return e.message
}
func (f *File) LoadByID(
ctx context.Context,
conn pg.Conn,
@@ -100,7 +84,7 @@ LIMIT 1;
file, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[File])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrFileNotFound{Identifier: fileID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect file: %w", err)
@@ -165,9 +149,7 @@ VALUES (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "files_file_key_key" {
return &ErrFileAlreadyExists{
message: fmt.Sprintf("file with file_key %q already exists", f.FileKey),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert file: %w", err)

View File

@@ -42,33 +42,8 @@ type (
}
Frameworks []*Framework
ErrFrameworkNotFound struct {
Identifier string
}
ErrFrameworkAlreadyExists struct {
message string
}
ErrFrameworkReferenceIDAlreadyExists struct {
ReferenceID string
OrganizationID gid.GID
}
)
func (e ErrFrameworkNotFound) Error() string {
return fmt.Sprintf("framework not found: %q", e.Identifier)
}
func (e ErrFrameworkAlreadyExists) Error() string {
return e.message
}
func (e ErrFrameworkReferenceIDAlreadyExists) Error() string {
return fmt.Sprintf("framework with reference ID %q already exists for organization %s", e.ReferenceID, e.OrganizationID)
}
func (f *Framework) CursorKey(orderBy FrameworkOrderField) page.CursorKey {
switch orderBy {
case FrameworkOrderFieldCreatedAt:
@@ -192,7 +167,7 @@ LIMIT 1;
framework, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Framework])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrFrameworkNotFound{Identifier: referenceID}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect framework: %w", err)
@@ -240,7 +215,7 @@ LIMIT 1;
framework, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Framework])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrFrameworkNotFound{Identifier: frameworkID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect framework: %w", err)
@@ -302,10 +277,7 @@ VALUES (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "frameworks_org_ref_unique" {
return &ErrFrameworkReferenceIDAlreadyExists{
ReferenceID: f.ReferenceID,
OrganizationID: f.OrganizationID,
}
return ErrResourceAlreadyExists
}
}

View File

@@ -42,24 +42,8 @@ type (
}
Invitations []*Invitation
InvitationData struct {
InvitationID gid.GID `json:"invitation_id"`
OrganizationID gid.GID `json:"organization_id"`
Email mail.Addr `json:"email"`
FullName string `json:"full_name"`
Role MembershipRole `json:"role"`
}
ErrInvitationNotFound struct {
ID string
}
)
func (e ErrInvitationNotFound) Error() string {
return fmt.Sprintf("invitation not found: %s", e.ID)
}
func (i Invitation) CursorKey(orderBy InvitationOrderField) page.CursorKey {
switch orderBy {
case InvitationOrderFieldFullName:
@@ -83,7 +67,7 @@ func (i Invitation) CursorKey(orderBy InvitationOrderField) page.CursorKey {
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
}
func (i *Invitation) Create(ctx context.Context, conn pg.Conn, scope Scoper) error {
func (i *Invitation) Insert(ctx context.Context, conn pg.Conn, scope Scoper) error {
query := `
INSERT INTO
authz_invitations (
@@ -170,8 +154,9 @@ WHERE
invitation, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Invitation])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrInvitationNotFound{ID: id.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect invitation: %w", err)
}
@@ -204,25 +189,25 @@ WHERE
}
if result.RowsAffected() == 0 {
return ErrInvitationNotFound{ID: i.ID.String()}
return ErrResourceNotFound
}
return nil
}
func (i *Invitation) Delete(ctx context.Context, conn pg.Conn, scope Scoper) error {
func (i *Invitation) Delete(ctx context.Context, conn pg.Conn, scope Scoper, invitationID gid.GID) error {
query := `
DELETE FROM
authz_invitations
WHERE
id = @id
AND %s
%s
AND id = @invitation_id
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"id": i.ID,
"invitation_id": invitationID,
}
maps.Copy(args, scope.SQLArguments())
@@ -232,13 +217,13 @@ WHERE
}
if result.RowsAffected() == 0 {
return ErrInvitationNotFound{ID: i.ID.String()}
return ErrResourceNotFound
}
return nil
}
func (i *Invitations) LoadByEmail(
func (i *Invitations) LoadByIdentityID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
@@ -386,7 +371,7 @@ WHERE
func (i *Invitations) CountByEmail(
ctx context.Context,
conn pg.Conn,
email string,
email mail.Addr,
filter *InvitationFilter,
) (int, error) {
q := `

View File

@@ -43,24 +43,8 @@ type (
}
Measures []*Measure
ErrMeasureNotFound struct {
Identifier string
}
ErrMeasureAlreadyExists struct {
message string
}
)
func (e ErrMeasureNotFound) Error() string {
return fmt.Sprintf("measure not found: %q", e.Identifier)
}
func (e ErrMeasureAlreadyExists) Error() string {
return e.message
}
func (m Measure) CursorKey(orderBy MeasureOrderField) page.CursorKey {
switch orderBy {
case MeasureOrderFieldCreatedAt:
@@ -414,7 +398,7 @@ LIMIT 1;
measure, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Measure])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrMeasureNotFound{Identifier: measureID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect measures: %w", err)
@@ -552,9 +536,7 @@ VALUES (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "mitigations_org_ref_unique" {
return &ErrMeasureAlreadyExists{
message: fmt.Sprintf("measure with organization_id %s and reference_id %q already exists", m.OrganizationID, m.ReferenceID),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert measure: %w", err)

View File

@@ -39,24 +39,8 @@ type (
}
Meetings []*Meeting
ErrMeetingNotFound struct {
Identifier string
}
ErrMeetingAlreadyExists struct {
message string
}
)
func (e ErrMeetingNotFound) Error() string {
return fmt.Sprintf("meeting not found: %s", e.Identifier)
}
func (e ErrMeetingAlreadyExists) Error() string {
return e.message
}
func (m Meeting) CursorKey(orderBy MeetingOrderField) page.CursorKey {
switch orderBy {
case MeetingOrderFieldCreatedAt:
@@ -106,7 +90,7 @@ LIMIT 1;
meeting, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Meeting])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrMeetingNotFound{Identifier: meetingID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect meeting: %w", err)
@@ -271,7 +255,7 @@ WHERE %s
}
if result.RowsAffected() == 0 {
return &ErrMeetingNotFound{Identifier: m.ID.String()}
return ErrResourceNotFound
}
return nil
@@ -300,7 +284,7 @@ WHERE %s
}
if result.RowsAffected() == 0 {
return &ErrMeetingNotFound{Identifier: m.ID.String()}
return ErrResourceNotFound
}
return nil

View File

@@ -43,26 +43,8 @@ type (
}
Memberships []*Membership
ErrMembershipNotFound struct {
UserID gid.GID
OrgID gid.GID
}
ErrMembershipAlreadyExists struct {
UserID gid.GID
OrgID gid.GID
}
)
func (e ErrMembershipNotFound) Error() string {
return fmt.Sprintf("membership not found for user %s in organization %s", e.UserID, e.OrgID)
}
func (e ErrMembershipAlreadyExists) Error() string {
return fmt.Sprintf("membership already exists for user %s in organization %s", e.UserID, e.OrgID)
}
func (m Membership) CursorKey(orderBy MembershipOrderField) page.CursorKey {
switch orderBy {
case MembershipOrderFieldFullName:
@@ -78,7 +60,46 @@ func (m Membership) CursorKey(orderBy MembershipOrderField) page.CursorKey {
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
}
func (m *Membership) Create(ctx context.Context, conn pg.Conn, scope Scoper) error {
func (m *Membership) LoadByUserInOrganization(ctx context.Context, conn pg.Conn, userID gid.GID, organizationID gid.GID) error {
q := `
SELECT
id,
user_id,
organization_id,
role,
created_at,
updated_at
FROM
authz_memberships
WHERE
user_id = @user_id
AND organization_id = @organization_id
`
args := pgx.StrictNamedArgs{
"user_id": userID,
"organization_id": organizationID,
}
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query membership: %w", err)
}
membership, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Membership])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect membership: %w", err)
}
*m = membership
return nil
}
func (m *Membership) Insert(ctx context.Context, conn pg.Conn, scope Scoper) error {
query := `
INSERT INTO
authz_memberships (
@@ -115,8 +136,9 @@ VALUES (
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return ErrMembershipAlreadyExists{UserID: m.UserID, OrgID: m.OrganizationID}
return ErrResourceAlreadyExists
}
return fmt.Errorf("cannot create membership: %w", err)
}
@@ -178,8 +200,9 @@ JOIN
membership, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Membership])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrMembershipNotFound{UserID: gid.GID{}, OrgID: gid.GID{}}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect membership: %w", err)
}
@@ -243,7 +266,7 @@ LIMIT 1;
defer rows.Close()
if !rows.Next() {
return &ErrMembershipNotFound{UserID: userID, OrgID: entityID}
return ErrResourceNotFound
}
var membership Membership
@@ -269,9 +292,9 @@ func (m *Membership) LoadByUserAndOrg(
conn pg.Conn,
scope Scoper,
userID gid.GID,
orgID gid.GID,
organizationID gid.GID,
) error {
query := `
q := `
WITH mbr AS (
SELECT
am.id,
@@ -302,20 +325,15 @@ JOIN
users u ON mbr.user_id = u.id
`
// Build scope fragment with table alias
scopeFragment := scope.SQLFragment()
// Replace column references with table-qualified versions
scopeFragment = strings.ReplaceAll(scopeFragment, "tenant_id =", "am.tenant_id =")
query = fmt.Sprintf(query, scopeFragment)
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"user_id": userID,
"organization_id": orgID,
"organization_id": organizationID,
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, query, args)
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query membership: %w", err)
}
@@ -323,8 +341,9 @@ JOIN
membership, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Membership])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrMembershipNotFound{UserID: userID, OrgID: orgID}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect membership: %w", err)
}
@@ -359,25 +378,25 @@ WHERE
}
if result.RowsAffected() == 0 {
return ErrMembershipNotFound{UserID: m.UserID, OrgID: m.OrganizationID}
return ErrResourceNotFound
}
return nil
}
func (m *Membership) Delete(ctx context.Context, conn pg.Conn, scope Scoper) error {
func (m *Membership) Delete(ctx context.Context, conn pg.Conn, scope Scoper, membershipID gid.GID) error {
query := `
DELETE FROM
authz_memberships
WHERE
id = @id
AND %s
%s
AND id = @membership_id
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"id": m.ID,
"membership_id": membershipID,
}
maps.Copy(args, scope.SQLArguments())
@@ -387,7 +406,7 @@ WHERE
}
if result.RowsAffected() == 0 {
return ErrMembershipNotFound{UserID: m.UserID, OrgID: m.OrganizationID}
return ErrResourceNotFound
}
return nil
@@ -398,6 +417,7 @@ func (m *Memberships) LoadByUserID(
conn pg.Conn,
scope Scoper,
userID gid.GID,
cursor *page.Cursor[MembershipOrderField],
) error {
query := `
WITH mbr AS (
@@ -552,3 +572,29 @@ WHERE
}
return count, nil
}
func (m *Memberships) CountByUserID(
ctx context.Context,
conn pg.Conn,
userID gid.GID,
) (int, error) {
query := `
SELECT
COUNT(*)
FROM
authz_memberships
WHERE
user_id = @user_id
`
args := pgx.StrictNamedArgs{
"user_id": userID,
}
row := conn.QueryRow(ctx, query, args)
var count int
if err := row.Scan(&count); err != nil {
return 0, fmt.Errorf("cannot count memberships: %w", err)
}
return count, nil
}

View File

@@ -0,0 +1,9 @@
CREATE TYPE session_expire_reason AS ENUM (
'idle_timeout',
'revoked',
'closed'
);
ALTER TABLE sessions ADD COLUMN expire_reason session_expire_reason;
UPDATE sessions SET expire_reason = 'idle_timeout' WHERE expired_at < NOW();

View File

@@ -0,0 +1,2 @@
ALTER TABLE sessions ADD COLUMN user_agent TEXT DEFAULT 'SESSION_CREATED_BEFORE_USER_AGENT_COLUMN_ADDED';
ALTER TABLE sessions ADD COLUMN ip_address INET DEFAULT '::1';

View File

@@ -0,0 +1,8 @@
ALTER TABLE sessions ADD COLUMN tenant_id TEXT;
ALTER TABLE sessions ADD COLUMN parent_session_id TEXT REFERENCES sessions(id);
ALTER TABLE sessions ADD CONSTRAINT session_tenant_check CHECK (
(parent_session_id IS NULL AND tenant_id IS NULL) OR
(parent_session_id IS NOT NULL AND tenant_id IS NOT NULL)
);

View File

@@ -0,0 +1,2 @@
ALTER TABLE auth_user_api_keys ADD COLUMN expire_reason TEXT;

View File

@@ -44,24 +44,8 @@ type (
}
Organizations []*Organization
ErrOrganizationNotFound struct {
Identifier string
}
ErrOrganizationAlreadyExists struct {
message string
}
)
func (e ErrOrganizationNotFound) Error() string {
return fmt.Sprintf("organization not found: %q", e.Identifier)
}
func (e ErrOrganizationAlreadyExists) Error() string {
return e.message
}
func (o Organization) CursorKey(orderBy OrganizationOrderField) page.CursorKey {
switch orderBy {
case OrganizationOrderFieldName:
@@ -116,7 +100,7 @@ LIMIT 1;
organization, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Organization])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrOrganizationNotFound{Identifier: organizationID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect organization: %w", err)
@@ -442,19 +426,14 @@ WHERE
func (o *Organization) Delete(
ctx context.Context,
conn pg.Conn,
scope Scoper,
organizationID gid.GID,
) error {
q := `
DELETE FROM organizations
WHERE
%s
AND id = @id
WHERE id = @id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"id": o.ID}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args)
if err != nil {
@@ -505,7 +484,7 @@ LIMIT 1
organization, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Organization])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrOrganizationNotFound{Identifier: customDomainID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect organization: %w", err)

View File

@@ -33,16 +33,8 @@ type (
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
ErrOrganizationContextNotFound struct {
Identifier string
}
)
func (e ErrOrganizationContextNotFound) Error() string {
return fmt.Sprintf("organization context not found: %q", e.Identifier)
}
func (oc *OrganizationContext) LoadByOrganizationID(
ctx context.Context,
conn pg.Conn,
@@ -76,7 +68,7 @@ LIMIT 1;
orgContext, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OrganizationContext])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrOrganizationContextNotFound{Identifier: organizationID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect organization context: %w", err)
@@ -155,7 +147,7 @@ WHERE
}
if result.RowsAffected() == 0 {
return &ErrOrganizationContextNotFound{Identifier: oc.OrganizationID.String()}
return ErrResourceNotFound
}
return nil

View File

@@ -45,32 +45,8 @@ type (
}
Peoples []*People
ErrPeopleNotFound struct {
Identifier string
}
ErrPeopleAlreadyExists struct {
message string
}
ErrPeopleReferenced struct {
message string
}
)
func (e ErrPeopleNotFound) Error() string {
return fmt.Sprintf("people not found: %s", e.Identifier)
}
func (e ErrPeopleAlreadyExists) Error() string {
return e.message
}
func (e ErrPeopleReferenced) Error() string {
return e.message
}
func (p People) CursorKey(orderBy PeopleOrderField) page.CursorKey {
switch orderBy {
case PeopleOrderFieldCreatedAt:
@@ -124,7 +100,7 @@ LIMIT 1;
people, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[People])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrPeopleNotFound{Identifier: peopleID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect people: %w", err)
@@ -175,7 +151,7 @@ LIMIT 1;
people, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[People])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrPeopleNotFound{Identifier: primaryEmailAddress}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect people: %w", err)
@@ -231,7 +207,7 @@ LIMIT 1;
people, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[People])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrPeopleNotFound{Identifier: primaryEmailAddress.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect people: %w", err)
@@ -362,9 +338,7 @@ DELETE FROM peoples WHERE %s AND id = @people_id
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23503" {
return &ErrPeopleReferenced{
message: fmt.Sprintf("person with id %s cannot be deleted because it is referenced by other records", p.ID),
}
return ErrResourceInUse
}
}
return fmt.Errorf("cannot delete person: %w", err)

View File

@@ -79,7 +79,7 @@ LIMIT 1;
report, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Report])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrDocumentNotFound{Identifier: reportID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect report: %w", err)

View File

@@ -57,24 +57,8 @@ type (
RiskSnapshotter interface {
InsertRiskSnapshots(ctx context.Context, conn pg.Conn, scope Scoper, organizationID, snapshotID gid.GID) error
}
ErrRiskNotFound struct {
Identifier string
}
ErrRiskAlreadyExists struct {
message string
}
)
func (e ErrRiskNotFound) Error() string {
return fmt.Sprintf("risk not found: %q", e.Identifier)
}
func (e ErrRiskAlreadyExists) Error() string {
return e.message
}
func (r *Risk) CursorKey(orderBy RiskOrderField) page.CursorKey {
switch orderBy {
case RiskOrderFieldCreatedAt:
@@ -392,7 +376,7 @@ LIMIT 1;
risk, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Risk])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrRiskNotFound{Identifier: riskID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect risk: %w", err)

View File

@@ -16,12 +16,14 @@ package coredata
import (
"context"
"errors"
"fmt"
"time"
"go.probo.inc/probo/pkg/gid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
)
type SAMLAssertion struct {
@@ -31,27 +33,17 @@ type SAMLAssertion struct {
ExpiresAt time.Time `db:"expires_at"`
}
type ErrAssertionAlreadyUsed struct {
AssertionID string
}
func (e ErrAssertionAlreadyUsed) Error() string {
return fmt.Sprintf("assertion ID %q has already been used (replay attack)", e.AssertionID)
}
func (s *SAMLAssertion) Insert(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
query := `
INSERT INTO auth_saml_assertions (id, tenant_id, organization_id, used_at, expires_at)
VALUES (@id, @tenant_id, @organization_id, @used_at, @expires_at)
INSERT INTO auth_saml_assertions (id, organization_id, used_at, expires_at)
VALUES (@id, @organization_id, @used_at, @expires_at)
`
args := pgx.NamedArgs{
"id": s.ID,
"tenant_id": scope.GetTenantID(),
"organization_id": s.OrganizationID,
"used_at": s.UsedAt,
"expires_at": s.ExpiresAt,
@@ -59,6 +51,11 @@ VALUES (@id, @tenant_id, @organization_id, @used_at, @expires_at)
_, err := conn.Exec(ctx, query, args)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" && pgErr.ConstraintName == "auth_saml_assertions_pkey" {
return ErrResourceAlreadyExists
}
return fmt.Errorf("cannot insert saml_assertion: %w", err)
}

View File

@@ -16,6 +16,8 @@ package coredata
import (
"context"
"crypto/x509"
"encoding/pem"
"fmt"
"maps"
"time"
@@ -23,28 +25,55 @@ import (
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type SAMLConfiguration struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
EmailDomain string `db:"email_domain"`
Enabled bool `db:"enabled"`
EnforcementPolicy SAMLEnforcementPolicy `db:"enforcement_policy"`
IdPEntityID string `db:"idp_entity_id"`
IdPSsoURL string `db:"idp_sso_url"`
IdPCertificate string `db:"idp_certificate"`
IdPMetadataURL *string `db:"idp_metadata_url"`
AttributeEmail string `db:"attribute_email"`
AttributeFirstname string `db:"attribute_firstname"`
AttributeLastname string `db:"attribute_lastname"`
AttributeRole string `db:"attribute_role"`
AutoSignupEnabled bool `db:"auto_signup_enabled"`
DomainVerified bool `db:"domain_verified"`
DomainVerificationToken *string `db:"domain_verification_token"`
DomainVerifiedAt *time.Time `db:"domain_verified_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
type (
SAMLConfiguration struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
EmailDomain string `db:"email_domain"`
EnforcementPolicy SAMLEnforcementPolicy `db:"enforcement_policy"`
IdPEntityID string `db:"idp_entity_id"`
IdPSsoURL string `db:"idp_sso_url"`
IdPCertificate string `db:"idp_certificate"`
IdPMetadataURL *string `db:"idp_metadata_url"`
AttributeEmail string `db:"attribute_email"`
AttributeFirstname string `db:"attribute_firstname"`
AttributeLastname string `db:"attribute_lastname"`
AttributeRole string `db:"attribute_role"`
AutoSignupEnabled bool `db:"auto_signup_enabled"`
DomainVerified bool `db:"domain_verified"`
DomainVerificationToken *string `db:"domain_verification_token"`
DomainVerifiedAt *time.Time `db:"domain_verified_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
SAMLConfigurations []*SAMLConfiguration
)
func (s *SAMLConfiguration) CursorKey(orderBy SAMLConfigurationOrderField) page.CursorKey {
switch orderBy {
case SAMLConfigurationOrderFieldCreatedAt:
return page.NewCursorKey(s.ID, s.CreatedAt)
}
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
}
func (s *SAMLConfiguration) GetIdPCertificate() (*x509.Certificate, error) {
block, _ := pem.Decode([]byte(s.IdPCertificate))
if block == nil {
return nil, fmt.Errorf("cannot decode PEM block from IdP certificate")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("cannot parse X.509 certificate: %w", err)
}
return cert, nil
}
func (s *SAMLConfiguration) LoadByOrganizationIDAndEmailDomain(
@@ -154,6 +183,10 @@ LIMIT 1;
config, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[SAMLConfiguration])
if err != nil {
if err == pgx.ErrNoRows {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect saml_configuration: %w", err)
}
@@ -173,7 +206,6 @@ INSERT INTO auth_saml_configurations (
tenant_id,
organization_id,
email_domain,
enabled,
enforcement_policy,
idp_entity_id,
idp_sso_url,
@@ -194,7 +226,6 @@ INSERT INTO auth_saml_configurations (
@tenant_id,
@organization_id,
@email_domain,
@enabled,
@enforcement_policy,
@idp_entity_id,
@idp_sso_url,
@@ -218,7 +249,6 @@ INSERT INTO auth_saml_configurations (
"tenant_id": scope.GetTenantID(),
"organization_id": s.OrganizationID,
"email_domain": s.EmailDomain,
"enabled": s.Enabled,
"enforcement_policy": s.EnforcementPolicy,
"idp_entity_id": s.IdPEntityID,
"idp_sso_url": s.IdPSsoURL,
@@ -252,7 +282,6 @@ func (s *SAMLConfiguration) Update(
q := `
UPDATE auth_saml_configurations
SET
enabled = @enabled,
enforcement_policy = @enforcement_policy,
idp_entity_id = @idp_entity_id,
idp_sso_url = @idp_sso_url,
@@ -276,7 +305,6 @@ WHERE
args := pgx.StrictNamedArgs{
"id": s.ID,
"enabled": s.Enabled,
"enforcement_policy": s.EnforcementPolicy,
"idp_entity_id": s.IdPEntityID,
"idp_sso_url": s.IdPSsoURL,
@@ -328,18 +356,17 @@ WHERE
return nil
}
func LoadSAMLConfigurationsByOrganizationID(
func (s *SAMLConfigurations) LoadByOrganizationID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
organizationID gid.GID,
) ([]*SAMLConfiguration, error) {
) error {
q := `
SELECT
id,
organization_id,
email_domain,
enabled,
enforcement_policy,
idp_entity_id,
idp_sso_url,
@@ -370,20 +397,17 @@ ORDER BY email_domain ASC;
rows, err := conn.Query(ctx, q, args)
if err != nil {
return nil, fmt.Errorf("cannot query auth_saml_configurations: %w", err)
return fmt.Errorf("cannot query auth_saml_configurations: %w", err)
}
configs, err := pgx.CollectRows(rows, pgx.RowToStructByName[SAMLConfiguration])
samlConfigurations, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[SAMLConfiguration])
if err != nil {
return nil, fmt.Errorf("cannot collect saml_configurations: %w", err)
return fmt.Errorf("cannot collect saml_configurations: %w", err)
}
result := make([]*SAMLConfiguration, len(configs))
for i := range configs {
result[i] = &configs[i]
}
*s = samlConfigurations
return result, nil
return nil
}
// LoadAllEnabledSAMLConfigurationsByEmailDomain loads all enabled SAML configurations for a given email domain
@@ -398,7 +422,6 @@ SELECT
id,
organization_id,
email_domain,
enabled,
enforcement_policy,
idp_entity_id,
idp_sso_url,
@@ -458,7 +481,6 @@ SELECT
id,
organization_id,
email_domain,
enabled,
enforcement_policy,
idp_entity_id,
idp_sso_url,
@@ -503,3 +525,38 @@ WHERE
return result, nil
}
func (s *SAMLConfigurations) CountByOrganizationID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
organizationID gid.GID,
) (int, error) {
q := `
SELECT
COUNT(*)
FROM
auth_saml_configurations
WHERE
%s
AND organization_id = @organization_id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return 0, fmt.Errorf("cannot query auth_saml_configurations: %w", err)
}
var count int
err = rows.Scan(&count)
if err != nil {
return 0, fmt.Errorf("cannot collect count: %w", err)
}
return count, nil
}

View File

@@ -0,0 +1,40 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
type (
SAMLConfigurationOrderField string
)
const (
SAMLConfigurationOrderFieldCreatedAt SAMLConfigurationOrderField = "CREATED_AT"
)
func (p SAMLConfigurationOrderField) Column() string {
return string(p)
}
func (p SAMLConfigurationOrderField) String() string {
return string(p)
}
func (p SAMLConfigurationOrderField) MarshalText() ([]byte, error) {
return []byte(p.String()), nil
}
func (p *SAMLConfigurationOrderField) UnmarshalText(text []byte) error {
*p = SAMLConfigurationOrderField(text)
return nil
}

View File

@@ -19,9 +19,9 @@ import (
"fmt"
"time"
"go.probo.inc/probo/pkg/gid"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
)
type SAMLRequest struct {
@@ -31,37 +31,18 @@ type SAMLRequest struct {
ExpiresAt time.Time `db:"expires_at"`
}
type ErrSAMLRequestNotFound struct {
RequestID string
}
func (e ErrSAMLRequestNotFound) Error() string {
return fmt.Sprintf("SAML request ID %q not found", e.RequestID)
}
type ErrSAMLRequestExpired struct {
RequestID string
ExpiresAt time.Time
}
func (e ErrSAMLRequestExpired) Error() string {
return fmt.Sprintf("SAML request ID %q expired at %v", e.RequestID, e.ExpiresAt)
}
func (s *SAMLRequest) Insert(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
query := `
INSERT INTO auth_saml_requests (id, organization_id, tenant_id, created_at, expires_at)
VALUES (@id, @organization_id, @tenant_id, @created_at, @expires_at)
INSERT INTO auth_saml_requests (id, organization_id, created_at, expires_at)
VALUES (@id, @organization_id, @created_at, @expires_at)
`
args := pgx.NamedArgs{
"id": s.ID,
"organization_id": s.OrganizationID,
"tenant_id": scope.GetTenantID(),
"created_at": s.CreatedAt,
"expires_at": s.ExpiresAt,
}
@@ -99,8 +80,9 @@ LIMIT 1
req, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[SAMLRequest])
if err == pgx.ErrNoRows {
return ErrSAMLRequestNotFound{RequestID: requestID}
return ErrResourceNotFound
}
if err != nil {
return fmt.Errorf("cannot collect saml_request: %w", err)
}

View File

@@ -17,8 +17,8 @@ package coredata
import (
"fmt"
"go.probo.inc/probo/pkg/gid"
"github.com/jackc/pgx/v5"
"go.probo.inc/probo/pkg/gid"
)
type (
@@ -62,6 +62,10 @@ func NewScope(tenantID gid.TenantID) *Scope {
}
}
func NewScopeFromObjectID(objectID gid.GID) *Scope {
return NewScope(objectID.TenantID())
}
func (s *Scope) SQLArguments() pgx.StrictNamedArgs {
return pgx.StrictNamedArgs{
"tenant_id": s.tenantID,

View File

@@ -18,26 +18,35 @@ import (
"context"
"errors"
"fmt"
"maps"
"net"
"time"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
Session struct {
ID gid.GID `db:"id"`
UserID gid.GID `db:"user_id"`
Data SessionData `db:"data"`
ExpiredAt time.Time `db:"expired_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
ID gid.GID `db:"id"`
UserID gid.GID `db:"user_id"`
TenantID *gid.TenantID `db:"tenant_id"`
ParentSessionID *gid.GID `db:"parent_session_id"`
Data SessionData `db:"data"`
UserAgent string `db:"user_agent"`
IPAddress net.IP `db:"ip_address"`
ExpireReason *ExpireReason `db:"expire_reason"`
ExpiredAt time.Time `db:"expired_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
Sessions []*Session
SessionData struct {
PasswordAuthenticated bool `json:"password_authenticated"`
PasswordAuthenticated bool `json:"password_authenticated"`
SAMLAuthenticatedOrgs map[string]SAMLAuthInfo `json:"saml_authenticated_orgs,omitempty"`
}
@@ -46,33 +55,39 @@ type (
SAMLConfigID gid.GID `json:"saml_config_id"`
SAMLSubject string `json:"saml_subject"`
}
ErrSessionNotFound struct {
Identifier string
}
ErrSessionAlreadyExists struct {
message string
}
)
func (e ErrSessionNotFound) Error() string {
return fmt.Sprintf("session not found: %q", e.Identifier)
}
func (e ErrSessionAlreadyExists) Error() string {
return e.message
func NewRootSession(userID gid.GID, duration time.Duration) *Session {
return &Session{
ID: gid.New(gid.NilTenant, SessionEntityType),
UserID: userID,
ExpiredAt: time.Now().Add(duration),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
}
func (s Session) CursorKey(orderBy SessionOrderField) page.CursorKey {
switch orderBy {
case SessionOrderFieldCreatedAt:
return page.NewCursorKey(s.ID, s.CreatedAt)
case SessionOrderFieldExpiredAt:
return page.NewCursorKey(s.ID, s.ExpiredAt)
case SessionOrderFieldUpdatedAt:
return page.NewCursorKey(s.ID, s.UpdatedAt)
}
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
}
func (s *Session) IsRootSession() bool {
return s.ParentSessionID == nil
}
func (s *Session) IsChildSession() bool {
return s.ParentSessionID != nil
}
func (s *Session) LoadByID(
ctx context.Context,
conn pg.Conn,
@@ -82,7 +97,12 @@ func (s *Session) LoadByID(
SELECT
id,
user_id,
data,
tenant_id,
data,
parent_session_id,
expire_reason,
user_agent,
ip_address,
expired_at,
created_at,
updated_at
@@ -103,7 +123,7 @@ LIMIT 1;
session, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Session])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrSessionNotFound{Identifier: sessionID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect session: %w", err)
@@ -119,11 +139,16 @@ func (s *Session) Insert(
) error {
q := `
INSERT INTO
sessions (id, user_id, data, expired_at, created_at, updated_at)
sessions (id, user_id, tenant_id, data, parent_session_id, expire_reason, user_agent, ip_address, expired_at, created_at, updated_at)
VALUES (
@session_id,
@user_id,
@tenant_id,
@data,
@parent_session_id,
@expire_reason,
@user_agent,
@ip_address,
@expired_at,
@created_at,
@updated_at
@@ -131,12 +156,17 @@ VALUES (
`
args := pgx.StrictNamedArgs{
"session_id": s.ID,
"user_id": s.UserID,
"data": s.Data,
"expired_at": s.ExpiredAt,
"created_at": s.CreatedAt,
"updated_at": s.UpdatedAt,
"session_id": s.ID,
"user_id": s.UserID,
"tenant_id": s.TenantID,
"data": s.Data,
"parent_session_id": s.ParentSessionID,
"expire_reason": s.ExpireReason,
"user_agent": s.UserAgent,
"ip_address": s.IPAddress,
"expired_at": s.ExpiredAt,
"created_at": s.CreatedAt,
"updated_at": s.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)
@@ -152,36 +182,121 @@ UPDATE sessions
SET
expired_at = @expired_at,
updated_at = @updated_at,
user_agent = @user_agent,
ip_address = @ip_address,
expire_reason = @expire_reason,
data = @data
WHERE
id = @session_id
`
args := pgx.StrictNamedArgs{
"session_id": s.ID,
"data": s.Data,
"expired_at": s.ExpiredAt,
"updated_at": s.UpdatedAt,
"session_id": s.ID,
"user_agent": s.UserAgent,
"ip_address": s.IPAddress,
"expire_reason": s.ExpireReason,
"data": s.Data,
"expired_at": s.ExpiredAt,
"updated_at": s.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)
return err
result, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot update session: %w", err)
}
if result.RowsAffected() == 0 {
return ErrResourceNotFound
}
return nil
}
func DeleteSession(
ctx context.Context,
conn pg.Conn,
sessionID gid.GID,
) error {
func (s *Sessions) LoadByUserID(ctx context.Context, conn pg.Conn, userID gid.GID, cursor *page.Cursor[SessionOrderField]) error {
q := `
DELETE FROM
SELECT
id,
user_id,
tenant_id,
data,
parent_session_id,
expire_reason,
user_agent,
ip_address,
expired_at,
created_at,
updated_at
FROM
sessions
WHERE
id = @session_id
user_id = @user_id
AND %s
`
args := pgx.StrictNamedArgs{"session_id": sessionID}
q = fmt.Sprintf(q, cursor.SQLFragment())
_, err := conn.Exec(ctx, q, args)
return err
args := pgx.StrictNamedArgs{"user_id": userID}
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query sessions: %w", err)
}
sessions, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Session])
if err != nil {
return fmt.Errorf("cannot collect sessions: %w", err)
}
*s = sessions
return nil
}
func (s *Sessions) CountByUserID(ctx context.Context, conn pg.Conn, userID gid.GID) (int, error) {
q := `
SELECT
COUNT(*)
FROM
sessions
WHERE
user_id = @user_id
`
args := pgx.StrictNamedArgs{"user_id": userID}
row := conn.QueryRow(ctx, q, args)
var count int
if err := row.Scan(&count); err != nil {
return 0, fmt.Errorf("cannot scan count: %w", err)
}
return count, nil
}
func (s *Sessions) ExpireAllForUserExceptOneSession(ctx context.Context, conn pg.Conn, userID gid.GID, sessionID gid.GID) (int64, error) {
q := `
UPDATE sessions
SET
expired_at = NOW(),
updated_at = NOW(),
expire_reason = 'revoked'
WHERE
id != @session_id
AND user_id = @user_id
AND expire_reason IS NULL
`
args := pgx.StrictNamedArgs{
"session_id": sessionID,
"user_id": userID,
}
result, err := conn.Exec(ctx, q, args)
if err != nil {
return 0, fmt.Errorf("cannot query sessions: %w", err)
}
return result.RowsAffected(), nil
}

View File

@@ -20,9 +20,20 @@ type (
const (
SessionOrderFieldCreatedAt SessionOrderField = "CREATED_AT"
SessionOrderFieldExpiredAt SessionOrderField = "EXPIRED_AT"
SessionOrderFieldUpdatedAt SessionOrderField = "UPDATED_AT"
)
func (p SessionOrderField) Column() string {
switch p {
case SessionOrderFieldCreatedAt:
return "created_at"
case SessionOrderFieldExpiredAt:
return "expired_at"
case SessionOrderFieldUpdatedAt:
return "updated_at"
}
return string(p)
}

View File

@@ -41,24 +41,8 @@ type (
}
StatesOfApplicability []*StateOfApplicability
ErrStateOfApplicabilityNotFound struct {
Identifier string
}
ErrStateOfApplicabilityAlreadyExists struct {
message string
}
)
func (e ErrStateOfApplicabilityNotFound) Error() string {
return fmt.Sprintf("state of applicability not found: %s", e.Identifier)
}
func (e ErrStateOfApplicabilityAlreadyExists) Error() string {
return e.message
}
func (s StateOfApplicability) CursorKey(orderBy StateOfApplicabilityOrderField) page.CursorKey {
switch orderBy {
case StateOfApplicabilityOrderFieldCreatedAt:
@@ -107,7 +91,7 @@ LIMIT 1;
stateOfApplicability, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[StateOfApplicability])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrStateOfApplicabilityNotFound{Identifier: stateOfApplicabilityID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect state_of_applicability: %w", err)
@@ -246,9 +230,7 @@ VALUES (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" {
return &ErrStateOfApplicabilityAlreadyExists{
message: fmt.Sprintf("state of applicability with name %q already exists", s.Name),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert state_of_applicability: %w", err)
@@ -287,16 +269,14 @@ WHERE %s
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" {
return &ErrStateOfApplicabilityAlreadyExists{
message: fmt.Sprintf("state of applicability with name %q already exists", s.Name),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot update state_of_applicability: %w", err)
}
if result.RowsAffected() == 0 {
return &ErrStateOfApplicabilityNotFound{Identifier: s.ID.String()}
return ErrResourceNotFound
}
return nil
@@ -325,7 +305,7 @@ WHERE %s
}
if result.RowsAffected() == 0 {
return &ErrStateOfApplicabilityNotFound{Identifier: s.ID.String()}
return ErrResourceNotFound
}
return nil

View File

@@ -46,24 +46,8 @@ type (
}
Tasks []*Task
ErrTaskNotFound struct {
Identifier string
}
ErrTaskAlreadyExists struct {
message string
}
)
func (e ErrTaskNotFound) Error() string {
return fmt.Sprintf("task not found: %q", e.Identifier)
}
func (e ErrTaskAlreadyExists) Error() string {
return e.message
}
func (c Task) CursorKey(orderBy TaskOrderField) page.CursorKey {
switch orderBy {
case TaskOrderFieldCreatedAt:
@@ -114,7 +98,7 @@ LIMIT 1;
task, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Task])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrTaskNotFound{Identifier: taskID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect tasks: %w", err)
@@ -185,9 +169,7 @@ VALUES (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "tasks_reference_id_unique" {
return &ErrTaskAlreadyExists{
message: fmt.Sprintf("task with measure_id %s and reference_id %q already exists", c.MeasureID, c.ReferenceID),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert task: %w", err)

View File

@@ -21,11 +21,11 @@ import (
"maps"
"time"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
@@ -41,24 +41,8 @@ type (
}
TrustCenters []*TrustCenter
ErrTrustCenterNotFound struct {
Identifier string
}
ErrTrustCenterAlreadyExists struct {
message string
}
)
func (e ErrTrustCenterNotFound) Error() string {
return fmt.Sprintf("trust center not found: %q", e.Identifier)
}
func (e ErrTrustCenterAlreadyExists) Error() string {
return e.message
}
func (tc *TrustCenter) CursorKey(orderBy TrustCenterOrderField) page.CursorKey {
switch orderBy {
case TrustCenterOrderFieldCreatedAt:
@@ -239,9 +223,7 @@ INSERT INTO trust_centers (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "trust_centers_slug_key" {
return &ErrTrustCenterAlreadyExists{
message: fmt.Sprintf("trust center with slug %q already exists", tc.Slug),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert trust center: %w", err)

View File

@@ -48,24 +48,8 @@ type (
}
TrustCenterAccesses []*TrustCenterAccess
ErrTrustCenterAccessNotFound struct {
Identifier string
}
ErrTrustCenterAccessAlreadyExists struct {
message string
}
)
func (e ErrTrustCenterAccessNotFound) Error() string {
return fmt.Sprintf("trust center access not found: %s", e.Identifier)
}
func (e ErrTrustCenterAccessAlreadyExists) Error() string {
return e.message
}
func (tca *TrustCenterAccess) CursorKey(orderBy TrustCenterAccessOrderField) page.CursorKey {
switch orderBy {
case TrustCenterAccessOrderFieldCreatedAt:
@@ -117,7 +101,7 @@ LIMIT 1;
access, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[TrustCenterAccess])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrTrustCenterAccessNotFound{Identifier: accessID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect trust center access: %w", err)
@@ -175,7 +159,7 @@ LIMIT 1;
access, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[TrustCenterAccess])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrTrustCenterAccessNotFound{Identifier: fmt.Sprintf("trust_center_id=%s, email=%s", trustCenterID, email)}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect trust center access: %w", err)
@@ -235,9 +219,7 @@ INSERT INTO trust_center_accesses (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "trust_center_accesses_trust_center_id_email_key" {
return &ErrTrustCenterAccessAlreadyExists{
message: "trust center access already exists",
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert trust center access: %w", err)

View File

@@ -42,24 +42,8 @@ type (
}
TrustCenterDocumentAccesses []*TrustCenterDocumentAccess
ErrTrustCenterDocumentAccessNotFound struct {
Identifier string
}
ErrTrustCenterDocumentAccessAlreadyExists struct {
message string
}
)
func (e ErrTrustCenterDocumentAccessNotFound) Error() string {
return fmt.Sprintf("trust center document access not found: %s", e.Identifier)
}
func (e ErrTrustCenterDocumentAccessAlreadyExists) Error() string {
return e.message
}
func (tcda *TrustCenterDocumentAccess) CursorKey(orderBy TrustCenterDocumentAccessOrderField) page.CursorKey {
switch orderBy {
case TrustCenterDocumentAccessOrderFieldCreatedAt:
@@ -107,7 +91,7 @@ LIMIT 1;
access, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[TrustCenterDocumentAccess])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrTrustCenterDocumentAccessNotFound{Identifier: accessID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect trust center document access: %w", err)
}
@@ -267,18 +251,10 @@ INSERT INTO trust_center_document_accesses (
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" {
switch pgErr.ConstraintName {
case "trust_center_document_accesse_trust_center_access_id_docume_key":
return &ErrTrustCenterDocumentAccessAlreadyExists{
message: fmt.Sprintf("trust center document access with trust_center_access_id %s and document_id %s already exists", tcda.TrustCenterAccessID, tcda.DocumentID),
}
case "trust_center_document_accesse_trust_center_access_id_report_key":
return &ErrTrustCenterDocumentAccessAlreadyExists{
message: fmt.Sprintf("trust center document access with trust_center_access_id %s and report_id %s already exists", tcda.TrustCenterAccessID, tcda.ReportID),
}
case "trust_center_document_accesses_trust_center_file_id_key":
return &ErrTrustCenterDocumentAccessAlreadyExists{
message: fmt.Sprintf("trust center document access with trust_center_access_id %s and trust_center_file_id %s already exists", tcda.TrustCenterAccessID, tcda.TrustCenterFileID),
}
case "trust_center_document_accesse_trust_center_access_id_docume_key",
"trust_center_document_accesse_trust_center_access_id_report_key",
"trust_center_document_accesses_trust_center_file_id_key":
return ErrResourceAlreadyExists
}
}
}

View File

@@ -43,24 +43,8 @@ type (
}
TrustCenterReferences []*TrustCenterReference
ErrTrustCenterReferenceNotFound struct {
Identifier string
}
ErrTrustCenterReferenceAlreadyExists struct {
message string
}
)
func (e ErrTrustCenterReferenceNotFound) Error() string {
return fmt.Sprintf("trust center reference not found: %q", e.Identifier)
}
func (e ErrTrustCenterReferenceAlreadyExists) Error() string {
return e.message
}
func (t TrustCenterReference) CursorKey(orderBy TrustCenterReferenceOrderField) page.CursorKey {
switch orderBy {
case TrustCenterReferenceOrderFieldRank:
@@ -174,9 +158,7 @@ RETURNING rank;
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "trust_center_references_trust_center_id_rank_key" {
return &ErrTrustCenterReferenceAlreadyExists{
message: fmt.Sprintf("trust center reference with trust_center_id %s and rank already exists", t.TrustCenterID),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert trust center reference: %w", err)
@@ -221,7 +203,7 @@ WHERE
}
if result.RowsAffected() == 0 {
return ErrTrustCenterReferenceNotFound{Identifier: t.ID.String()}
return ErrResourceNotFound
}
return nil

View File

@@ -43,24 +43,8 @@ type (
}
Users []*User
ErrUserNotFound struct {
Identifier string
}
ErrUserAlreadyExists struct {
message string
}
)
func (e ErrUserNotFound) Error() string {
return fmt.Sprintf("user not found: %q", e.Identifier)
}
func (e ErrUserAlreadyExists) Error() string {
return e.message
}
func (u User) CursorKey(orderBy UserOrderField) page.CursorKey {
switch orderBy {
case UserOrderFieldCreatedAt:
@@ -180,7 +164,7 @@ LIMIT 1;
user, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[User])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrUserNotFound{Identifier: email.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect user: %w", err)
@@ -224,7 +208,7 @@ LIMIT 1;
user, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[User])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrUserNotFound{Identifier: userID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect user: %w", err)
@@ -238,7 +222,6 @@ LIMIT 1;
func (u *User) Insert(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
q := `
INSERT INTO
@@ -272,9 +255,7 @@ VALUES (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && strings.Contains(pgErr.ConstraintName, "email_address") {
return &ErrUserAlreadyExists{
message: fmt.Sprintf("user with email %s already exists", u.EmailAddress),
}
return ErrResourceAlreadyExists
}
}
@@ -284,71 +265,6 @@ VALUES (
return nil
}
func (u *User) UpdateEmailVerification(
ctx context.Context,
conn pg.Conn,
verified bool,
) error {
q := `
UPDATE
users
SET
email_address_verified = @email_address_verified,
updated_at = @updated_at
WHERE
id = @user_id
`
args := pgx.StrictNamedArgs{
"user_id": u.ID,
"email_address_verified": verified,
"updated_at": time.Now(),
}
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot update user email verification: %w", err)
}
u.EmailAddressVerified = verified
u.UpdatedAt = args["updated_at"].(time.Time)
return nil
}
func (u *User) UpdatePassword(
ctx context.Context,
conn pg.Conn,
hashedPassword []byte,
) error {
q := `
UPDATE
users
SET
hashed_password = @hashed_password,
updated_at = @updated_at
WHERE
id = @user_id
`
now := time.Now()
args := pgx.StrictNamedArgs{
"user_id": u.ID,
"hashed_password": hashedPassword,
"updated_at": now,
}
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot update user password: %w", err)
}
u.HashedPassword = hashedPassword
u.UpdatedAt = now
return nil
}
func (u *User) Update(ctx context.Context, conn pg.Conn) error {
q := `
UPDATE
@@ -357,6 +273,8 @@ SET
email_address = @email_address,
email_address_verified = @email_address_verified,
saml_subject = @saml_subject,
fullname = @fullname,
hashed_password = @hashed_password,
updated_at = @updated_at
WHERE
id = @user_id
@@ -368,13 +286,19 @@ WHERE
"email_address_verified": u.EmailAddressVerified,
"saml_subject": u.SAMLSubject,
"updated_at": u.UpdatedAt,
"fullname": u.FullName,
"hashed_password": u.HashedPassword,
}
_, err := conn.Exec(ctx, q, args)
result, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot update user: %w", err)
}
if result.RowsAffected() == 0 {
return ErrResourceNotFound
}
return nil
}
@@ -411,7 +335,7 @@ LIMIT 1;
user, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[User])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrUserNotFound{Identifier: samlSubject}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect user: %w", err)
@@ -422,10 +346,6 @@ LIMIT 1;
return nil
}
// LoadByEmailAndTenant, LoadByEmailGlobal, and IsTenantUser methods removed
// All users are now global (no tenant_id distinction)
// Use LoadByEmail() for all email-based lookups
func (u *User) CountMemberships(
ctx context.Context,
conn pg.Conn,

View File

@@ -23,27 +23,30 @@ import (
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
UserAPIKey struct {
ID gid.GID `db:"id"`
UserID gid.GID `db:"user_id"`
Name string `db:"name"`
ExpiresAt time.Time `db:"expires_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
ID gid.GID `db:"id"`
UserID gid.GID `db:"user_id"`
Name string `db:"name"`
ExpiresAt time.Time `db:"expires_at"`
ExpireReason *ExpireReason `db:"expire_reason"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
UserAPIKeys []*UserAPIKey
ErrUserAPIKeyNotFound struct {
Identifier string
}
)
func (e ErrUserAPIKeyNotFound) Error() string {
return fmt.Sprintf("user api key not found: %q", e.Identifier)
func (a *UserAPIKey) CursorKey(orderBy UserAPIKeyOrderField) page.CursorKey {
switch orderBy {
case UserAPIKeyOrderFieldCreatedAt:
return page.NewCursorKey(a.ID, a.CreatedAt)
}
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
}
func (a *UserAPIKey) LoadByID(
@@ -57,6 +60,7 @@ SELECT
user_id,
name,
expires_at,
expire_reason,
created_at,
updated_at
FROM
@@ -76,7 +80,7 @@ LIMIT 1;
apiKey, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[UserAPIKey])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrUserAPIKeyNotFound{Identifier: apiKeyID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect user api key: %w", err)
@@ -98,6 +102,7 @@ SELECT
user_id,
name,
expires_at,
expire_reason,
created_at,
updated_at
FROM
@@ -124,30 +129,53 @@ ORDER BY created_at DESC;
return nil
}
func (a *UserAPIKeys) CountByUserID(ctx context.Context, conn pg.Conn, userID gid.GID) (int, error) {
q := `
SELECT
COUNT(*)
FROM
auth_user_api_keys
WHERE
user_id = @user_id
ORDER BY created_at DESC;
`
args := pgx.StrictNamedArgs{"user_id": userID}
row := conn.QueryRow(ctx, q, args)
var count int
if err := row.Scan(&count); err != nil {
return 0, fmt.Errorf("cannot scan count: %w", err)
}
return count, nil
}
func (a *UserAPIKey) Insert(
ctx context.Context,
conn pg.Conn,
) error {
q := `
INSERT INTO
auth_user_api_keys (id, user_id, name, expires_at, created_at, updated_at)
auth_user_api_keys (id, user_id, name, expires_at, expire_reason, created_at, updated_at)
VALUES (
@api_key_id,
@user_id,
@name,
@expires_at,
@expire_reason,
@created_at,
@updated_at
)
`
args := pgx.StrictNamedArgs{
"api_key_id": a.ID,
"user_id": a.UserID,
"name": a.Name,
"expires_at": a.ExpiresAt,
"created_at": a.CreatedAt,
"updated_at": a.UpdatedAt,
"api_key_id": a.ID,
"user_id": a.UserID,
"name": a.Name,
"expires_at": a.ExpiresAt,
"expire_reason": a.ExpireReason,
"created_at": a.CreatedAt,
"updated_at": a.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)
@@ -168,16 +196,18 @@ UPDATE
SET
name = @name,
expires_at = @expires_at,
expire_reason = @expire_reason,
updated_at = @updated_at
WHERE
id = @api_key_id
`
args := pgx.StrictNamedArgs{
"api_key_id": a.ID,
"name": a.Name,
"expires_at": a.ExpiresAt,
"updated_at": a.UpdatedAt,
"api_key_id": a.ID,
"name": a.Name,
"expires_at": a.ExpiresAt,
"expire_reason": a.ExpireReason,
"updated_at": a.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)

View File

@@ -0,0 +1,40 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
type (
UserAPIKeyOrderField string
)
const (
UserAPIKeyOrderFieldCreatedAt UserAPIKeyOrderField = "CREATED_AT"
)
func (p UserAPIKeyOrderField) Column() string {
return string(p)
}
func (p UserAPIKeyOrderField) String() string {
return string(p)
}
func (p UserAPIKeyOrderField) MarshalText() ([]byte, error) {
return []byte(p.String()), nil
}
func (p *UserAPIKeyOrderField) UnmarshalText(text []byte) error {
*p = UserAPIKeyOrderField(text)
return nil
}

View File

@@ -63,24 +63,8 @@ type (
VendorSnapshotter interface {
InsertVendorSnapshots(ctx context.Context, conn pg.Conn, scope Scoper, organizationID, snapshotID gid.GID) error
}
ErrVendorNotFound struct {
Identifier string
}
ErrVendorAlreadyExists struct {
message string
}
)
func (e ErrVendorNotFound) Error() string {
return fmt.Sprintf("vendor not found: %q", e.Identifier)
}
func (e ErrVendorAlreadyExists) Error() string {
return e.message
}
func (v Vendor) CursorKey(orderBy VendorOrderField) page.CursorKey {
switch orderBy {
case VendorOrderFieldCreatedAt:
@@ -151,7 +135,7 @@ LIMIT 1;
vendor, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Vendor])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrVendorNotFound{Identifier: vendorID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect vendor: %w", err)

View File

@@ -21,11 +21,11 @@ import (
"maps"
"time"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
@@ -43,24 +43,8 @@ type (
}
VendorBusinessAssociateAgreements []*VendorBusinessAssociateAgreement
ErrVendorBusinessAssociateAgreementNotFound struct {
Identifier string
}
ErrVendorBusinessAssociateAgreementAlreadyExists struct {
message string
}
)
func (e ErrVendorBusinessAssociateAgreementNotFound) Error() string {
return fmt.Sprintf("vendor business associate agreement not found: %q", e.Identifier)
}
func (e ErrVendorBusinessAssociateAgreementAlreadyExists) Error() string {
return e.message
}
func (v VendorBusinessAssociateAgreement) CursorKey(orderBy VendorBusinessAssociateAgreementOrderField) page.CursorKey {
switch orderBy {
case VendorBusinessAssociateAgreementOrderFieldValidFrom:
@@ -263,9 +247,7 @@ ON CONFLICT (organization_id, vendor_id) DO UPDATE SET
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "vendor_business_associate_agreements_source_id_snapshot_id_key" {
return &ErrVendorBusinessAssociateAgreementAlreadyExists{
message: fmt.Sprintf("vendor business associate agreement with source_id %s and snapshot_id %s already exists", vbaa.SourceID, vbaa.SnapshotID),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot upsert vendor business associate agreement: %w", err)

View File

@@ -44,16 +44,8 @@ type (
}
VendorContacts []*VendorContact
ErrVendorContactNotFound struct {
Identifier string
}
)
func (e ErrVendorContactNotFound) Error() string {
return fmt.Sprintf("vendor contact not found: %s", e.Identifier)
}
func (vc VendorContact) CursorKey(orderBy VendorContactOrderField) page.CursorKey {
switch orderBy {
case VendorContactOrderFieldCreatedAt:
@@ -108,7 +100,7 @@ LIMIT 1;
vendorContact, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[VendorContact])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrVendorContactNotFound{Identifier: vendorContactID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect vendor contact: %w", err)

View File

@@ -21,11 +21,11 @@ import (
"maps"
"time"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
@@ -43,24 +43,8 @@ type (
}
VendorDataPrivacyAgreements []*VendorDataPrivacyAgreement
ErrVendorDataPrivacyAgreementNotFound struct {
Identifier string
}
ErrVendorDataPrivacyAgreementAlreadyExists struct {
message string
}
)
func (e ErrVendorDataPrivacyAgreementNotFound) Error() string {
return fmt.Sprintf("vendor data privacy agreement not found: %q", e.Identifier)
}
func (e ErrVendorDataPrivacyAgreementAlreadyExists) Error() string {
return e.message
}
func (v VendorDataPrivacyAgreement) CursorKey(orderBy VendorDataPrivacyAgreementOrderField) page.CursorKey {
switch orderBy {
case VendorDataPrivacyAgreementOrderFieldValidFrom:
@@ -263,9 +247,7 @@ ON CONFLICT (organization_id, vendor_id) DO UPDATE SET
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "vendor_data_privacy_agreements_source_id_snapshot_id_key" {
return &ErrVendorDataPrivacyAgreementAlreadyExists{
message: fmt.Sprintf("vendor data privacy agreement with source_id %s and snapshot_id %s already exists", vdpa.SourceID, vdpa.SnapshotID),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot upsert vendor data privacy agreement: %w", err)

View File

@@ -41,16 +41,8 @@ type (
}
VendorServices []*VendorService
ErrVendorServiceNotFound struct {
Identifier string
}
)
func (e ErrVendorServiceNotFound) Error() string {
return fmt.Sprintf("vendor service not found: %s", e.Identifier)
}
func (vs VendorService) CursorKey(orderBy VendorServiceOrderField) page.CursorKey {
switch orderBy {
case VendorServiceOrderFieldCreatedAt:
@@ -101,7 +93,7 @@ LIMIT 1;
vendorService, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[VendorService])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrVendorServiceNotFound{Identifier: vendorServiceID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect vendor service: %w", err)