Reimplement invitations

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-02-17 00:26:22 +04:00
parent a42fc4fa61
commit 47aba96b69
43 changed files with 1651 additions and 4550 deletions

View File

@@ -24,17 +24,14 @@ import (
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/mail"
"go.probo.inc/probo/pkg/page"
)
type (
Invitation struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
Email mail.Addr `db:"email"`
FullName string `db:"full_name"`
Role MembershipRole `db:"role"`
OrganizationID gid.GID `fb:"organization_id"`
UserID gid.GID `db:"user_id"`
Status InvitationStatus `db:"status"`
ExpiresAt time.Time `db:"expires_at"`
AcceptedAt *time.Time `db:"accepted_at"`
@@ -46,22 +43,8 @@ type (
func (i Invitation) CursorKey(orderBy InvitationOrderField) page.CursorKey {
switch orderBy {
case InvitationOrderFieldFullName:
return page.NewCursorKey(i.ID, i.FullName)
case InvitationOrderFieldEmail:
return page.NewCursorKey(i.ID, i.Email)
case InvitationOrderFieldRole:
return page.NewCursorKey(i.ID, i.Role)
case InvitationOrderFieldCreatedAt:
return page.NewCursorKey(i.ID, i.CreatedAt)
case InvitationOrderFieldExpiresAt:
return page.NewCursorKey(i.ID, i.ExpiresAt)
case InvitationOrderFieldAcceptedAt:
acceptedAt := time.Time{}
if i.AcceptedAt != nil {
acceptedAt = *i.AcceptedAt
}
return page.NewCursorKey(i.ID, acceptedAt)
}
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
@@ -72,21 +55,17 @@ func (i *Invitation) Insert(ctx context.Context, conn pg.Conn, scope Scoper) err
INSERT INTO
iam_invitations (
tenant_id,
organization_id,
user_id,
id,
organization_id,
email,
full_name,
role,
expires_at,
created_at
)
VALUES (
@tenant_id,
@organization_id,
@user_id,
@id,
@organization_id,
@email,
@full_name,
@role,
@expires_at,
@created_at
);
@@ -94,11 +73,9 @@ VALUES (
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"id": i.ID,
"organization_id": i.OrganizationID,
"email": i.Email,
"full_name": i.FullName,
"role": i.Role,
"id": i.ID,
"user_id": i.UserID,
"expires_at": i.ExpiresAt,
"created_at": i.CreatedAt,
}
@@ -120,10 +97,8 @@ func (i *Invitation) LoadByID(
query := `
SELECT
id,
organization_id,
email,
full_name,
role,
organization_id,
user_id,
CASE
WHEN accepted_at IS NOT NULL THEN 'ACCEPTED'
WHEN expires_at < NOW() THEN 'EXPIRED'
@@ -164,49 +139,12 @@ WHERE
return nil
}
func (i *Invitations) ExpireByEmailAndOrganization(
ctx context.Context,
conn pg.Conn,
scope Scoper,
email mail.Addr,
organizationID gid.GID,
filter *InvitationFilter,
) error {
q := `
UPDATE
iam_invitations
SET
expires_at = NOW()
WHERE
email = @email
AND organization_id = @organization_id
AND %s
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
args := pgx.StrictNamedArgs{
"email": email,
"organization_id": organizationID,
}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
if _, err := conn.Exec(ctx, q, args); err != nil {
return fmt.Errorf("cannot expire invitations: %w", err)
}
return nil
}
// AuthorizationAttributes loads the minimal authorization attributes for policy condition evaluation.
// It is intentionally lightweight and does not populate the Invitation struct.
func (i *Invitation) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) {
q := `
SELECT
email
, organization_id
email, organization_id
FROM
iam_invitations
WHERE
@@ -288,11 +226,11 @@ WHERE
return nil
}
func (i *Invitations) LoadByIdentityID(
func (i *Invitations) LoadByUserID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
email mail.Addr,
userID gid.GID,
cursor *page.Cursor[InvitationOrderField],
filter *InvitationFilter,
) error {
@@ -300,9 +238,7 @@ func (i *Invitations) LoadByIdentityID(
SELECT
id,
organization_id,
email,
full_name,
role,
user_id,
CASE
WHEN accepted_at IS NOT NULL THEN 'ACCEPTED'
WHEN expires_at < NOW() THEN 'EXPIRED'
@@ -314,60 +250,7 @@ SELECT
FROM
iam_invitations
WHERE
email = @email
AND %s
AND %s
`
query = fmt.Sprintf(query, filter.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{
"email": email,
}
maps.Copy(args, filter.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot query invitations: %w", err)
}
invitations, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Invitation])
if err != nil {
return fmt.Errorf("cannot collect invitations: %w", err)
}
*i = invitations
return nil
}
func (i *Invitations) LoadByOrganizationID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
orgID gid.GID,
cursor *page.Cursor[InvitationOrderField],
filter *InvitationFilter,
) error {
query := `
SELECT
id,
organization_id,
email,
full_name,
role,
CASE
WHEN accepted_at IS NOT NULL THEN 'ACCEPTED'
WHEN expires_at < NOW() THEN 'EXPIRED'
ELSE 'PENDING'
END as status,
expires_at,
accepted_at,
created_at
FROM
iam_invitations
WHERE
organization_id = @organization_id
user_id = @user_id
AND %s
AND %s
AND %s
@@ -376,7 +259,7 @@ WHERE
query = fmt.Sprintf(query, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{
"organization_id": orgID,
"user_id": userID,
}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
@@ -396,73 +279,36 @@ WHERE
return nil
}
func (i *Invitations) CountByOrganizationID(
func (i *Invitations) ExpireByUserID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
orgID gid.GID,
userID gid.GID,
filter *InvitationFilter,
) (int, error) {
) error {
q := `
SELECT
COUNT(*)
FROM
iam_invitations
WHERE
organization_id = @organization_id AND %s AND %s
`
UPDATE
iam_invitations
SET
expires_at = NOW()
WHERE
user_id = @user_id
AND organization_id = @organization_id
AND %s
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
args := pgx.StrictNamedArgs{
"organization_id": orgID,
"user_id": userID,
}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
row := conn.QueryRow(ctx, q, args)
var count int
err := row.Scan(&count)
if err != nil {
return 0, fmt.Errorf("cannot count invitations: %w", err)
if _, err := conn.Exec(ctx, q, args); err != nil {
return fmt.Errorf("cannot expire invitations: %w", err)
}
return count, nil
}
// Tenant scope is not applied because this is used to count invitations across all tenants
// for a user who doesn't have tenant access yet (before accepting an invitation).
func (i *Invitations) CountByEmail(
ctx context.Context,
conn pg.Conn,
email mail.Addr,
filter *InvitationFilter,
) (int, error) {
q := `
SELECT
COUNT(*)
FROM
iam_invitations
WHERE
email = @email
AND %s
`
q = fmt.Sprintf(q, filter.SQLFragment())
args := pgx.StrictNamedArgs{
"email": email,
}
maps.Copy(args, filter.SQLArguments())
row := conn.QueryRow(ctx, q, args)
var count int
err := row.Scan(&count)
if err != nil {
return 0, fmt.Errorf("cannot count invitations: %w", err)
}
return count, nil
return nil
}

View File

@@ -21,28 +21,13 @@ type InvitationOrderField string
// InvitationOrderField constants
const (
InvitationOrderFieldFullName InvitationOrderField = "FULL_NAME"
InvitationOrderFieldEmail InvitationOrderField = "EMAIL"
InvitationOrderFieldRole InvitationOrderField = "ROLE"
InvitationOrderFieldCreatedAt InvitationOrderField = "CREATED_AT"
InvitationOrderFieldExpiresAt InvitationOrderField = "EXPIRES_AT"
InvitationOrderFieldAcceptedAt InvitationOrderField = "ACCEPTED_AT"
InvitationOrderFieldCreatedAt InvitationOrderField = "CREATED_AT"
)
func (p InvitationOrderField) Column() string {
switch p {
case InvitationOrderFieldFullName:
return "full_name"
case InvitationOrderFieldEmail:
return "email"
case InvitationOrderFieldRole:
return "role"
case InvitationOrderFieldCreatedAt:
return "created_at"
case InvitationOrderFieldExpiresAt:
return "expires_at"
case InvitationOrderFieldAcceptedAt:
return "accepted_at"
}
panic(fmt.Sprintf("unsupported order by: %s", p))
@@ -50,7 +35,7 @@ func (p InvitationOrderField) Column() string {
func (e InvitationOrderField) IsValid() bool {
switch e {
case InvitationOrderFieldFullName, InvitationOrderFieldEmail, InvitationOrderFieldRole, InvitationOrderFieldCreatedAt, InvitationOrderFieldExpiresAt, InvitationOrderFieldAcceptedAt:
case InvitationOrderFieldCreatedAt:
return true
}
return false

View File

@@ -49,3 +49,152 @@ ALTER TABLE
iam_scim_events
ALTER COLUMN
user_name DROP DEFAULT;
-- Convert invitations to identities / profiles / memberships
-- Create missing identities (one row per email to avoid "cannot affect row a second time")
INSERT INTO
identities (
id,
created_at,
updated_at,
email_address,
email_address_verified,
full_name
)
SELECT
generate_gid('\x0000000000000000' :: bytea, 11),
NOW(),
NOW(),
i.email,
FALSE,
i.full_name
FROM
(
SELECT
DISTINCT ON (email) email,
full_name
FROM
iam_invitations
WHERE
accepted_at IS NULL
ORDER BY
email
) i ON CONFLICT (email_address) DO
UPDATE
SET
full_name = EXCLUDED.full_name;
-- Create missing profiles
WITH invitation_identities AS (
SELECT
i.id AS identity_id,
inv.tenant_id AS tenant_id,
inv.organization_id AS organization_id,
inv.full_name AS full_name
FROM
iam_invitations inv
INNER JOIN identities i ON i.email_address = inv.email
WHERE
inv.accepted_at IS NULL
)
INSERT INTO
iam_membership_profiles (
id,
tenant_id,
identity_id,
organization_id,
full_name,
kind,
additional_email_addresses,
source,
state,
created_at,
updated_at
)
SELECT
generate_gid(decode_base64_unpadded(ii.tenant_id), 51),
ii.tenant_id,
ii.identity_id,
ii.organization_id,
ii.full_name,
'EMPLOYEE',
'{}' :: CITEXT [],
'MANUAL',
'INACTIVE',
NOW(),
NOW()
FROM
invitation_identities ii ON CONFLICT DO NOTHING;
-- Create missing memberships
WITH invitation_identities AS (
SELECT
i.id AS identity_id,
inv.tenant_id AS tenant_id,
inv.organization_id AS organization_id,
inv.role AS role
FROM
iam_invitations inv
INNER JOIN identities i ON i.email_address = inv.email
WHERE
inv.accepted_at IS NULL
)
INSERT INTO
iam_memberships (
id,
tenant_id,
identity_id,
organization_id,
role,
created_at,
updated_at
)
SELECT
generate_gid(decode_base64_unpadded(ii.tenant_id), 39),
ii.tenant_id,
ii.identity_id,
ii.organization_id,
ii.role,
NOW(),
NOW()
FROM
invitation_identities ii ON CONFLICT DO NOTHING;
ALTER TABLE
iam_invitations
ADD
COLUMN user_id TEXT REFERENCES iam_membership_profiles(id);
WITH profile_identities AS (
SELECT
p.id AS profile_id,
i.email_address,
p.organization_id
FROM
iam_membership_profiles p
INNER JOIN identities i ON i.id = p.identity_id
)
UPDATE
iam_invitations i
SET
user_id = pi.profile_id
FROM
profile_identities pi
WHERE
pi.organization_id = i.organization_id
AND pi.email_address = i.email;
ALTER TABLE
iam_invitations
ALTER COLUMN
user_id
SET
NOT NULL,
ALTER COLUMN
email DROP NOT NULL,
ALTER COLUMN
role TYPE TEXT USING role :: text,
ALTER COLUMN
role DROP NOT NULL,
ALTER COLUMN
full_name DROP NOT NULL;