Refactoring of authentification
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
committed by
Sacha Al Himdani
parent
afa7e4fdd5
commit
0f96b8518f
@@ -59,4 +59,6 @@ const (
|
||||
TrustCenterReferenceEntityType
|
||||
TrustCenterDocumentAccessEntityType
|
||||
CustomDomainEntityType
|
||||
InvitationEntityType
|
||||
MembershipEntityType
|
||||
)
|
||||
|
||||
280
pkg/coredata/invitation.go
Normal file
280
pkg/coredata/invitation.go
Normal file
@@ -0,0 +1,280 @@
|
||||
// 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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/getprobo/probo/pkg/page"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
)
|
||||
|
||||
type (
|
||||
Invitation struct {
|
||||
ID gid.GID `db:"id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
Email string `db:"email"`
|
||||
FullName string `db:"full_name"`
|
||||
Role string `db:"role"`
|
||||
ExpiresAt time.Time `db:"expires_at"`
|
||||
AcceptedAt *time.Time `db:"accepted_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
}
|
||||
|
||||
Invitations []*Invitation
|
||||
|
||||
InvitationData struct {
|
||||
InvitationID gid.GID `json:"invitation_id"`
|
||||
OrganizationID gid.GID `json:"organization_id"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"full_name"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
ErrInvitationNotFound struct {
|
||||
Token string
|
||||
}
|
||||
)
|
||||
|
||||
func (e ErrInvitationNotFound) Error() string {
|
||||
return fmt.Sprintf("invitation not found: %s", e.Token)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because invitations are managed at the organization level and don't require tenant isolation.
|
||||
func (i *Invitation) Create(ctx context.Context, conn pg.Conn) error {
|
||||
query := `
|
||||
INSERT INTO authz_invitations (
|
||||
id, organization_id, email, full_name, role, expires_at, created_at
|
||||
) VALUES (
|
||||
@id, @organization_id, @email, @full_name, @role, @expires_at, @created_at
|
||||
)
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": i.ID,
|
||||
"organization_id": i.OrganizationID,
|
||||
"email": i.Email,
|
||||
"full_name": i.FullName,
|
||||
"role": i.Role,
|
||||
"expires_at": i.ExpiresAt,
|
||||
"created_at": i.CreatedAt,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create invitation: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because we want to access invitations across all tenants for authentication purposes.
|
||||
func (i *Invitation) LoadByID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
id gid.GID,
|
||||
) error {
|
||||
query := `
|
||||
SELECT id, organization_id, email, full_name, role, expires_at, accepted_at, created_at
|
||||
FROM authz_invitations
|
||||
WHERE id = @id
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": id,
|
||||
}
|
||||
|
||||
rows, err := conn.Query(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query invitation: %w", err)
|
||||
}
|
||||
|
||||
invitation, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Invitation])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrInvitationNotFound{Token: id.String()}
|
||||
}
|
||||
return fmt.Errorf("cannot collect invitation: %w", err)
|
||||
}
|
||||
|
||||
*i = invitation
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because invitations are managed at the organization level and don't require tenant isolation.
|
||||
func (i *Invitation) Update(ctx context.Context, conn pg.Conn) error {
|
||||
query := `
|
||||
UPDATE authz_invitations
|
||||
SET accepted_at = @accepted_at
|
||||
WHERE id = @id
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": i.ID,
|
||||
"accepted_at": i.AcceptedAt,
|
||||
}
|
||||
|
||||
result, err := conn.Exec(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update invitation: %w", err)
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
return ErrInvitationNotFound{Token: i.ID.String()}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because invitations are managed at the organization level and don't require tenant isolation.
|
||||
func (i *Invitation) Delete(ctx context.Context, conn pg.Conn) error {
|
||||
query := `
|
||||
DELETE FROM authz_invitations
|
||||
WHERE id = @id
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": i.ID,
|
||||
}
|
||||
|
||||
result, err := conn.Exec(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete invitation: %w", err)
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
return ErrInvitationNotFound{Token: i.ID.String()}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *Invitations) LoadByEmail(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
email string,
|
||||
) error {
|
||||
query := `
|
||||
SELECT id, organization_id, email, full_name, role, expires_at, accepted_at, created_at
|
||||
FROM authz_invitations
|
||||
WHERE email = @email AND accepted_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"email": email,
|
||||
}
|
||||
|
||||
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,
|
||||
orgID gid.GID,
|
||||
cursor *page.Cursor[InvitationOrderField],
|
||||
) error {
|
||||
query := `
|
||||
SELECT id, organization_id, email, full_name, role, expires_at, accepted_at, created_at
|
||||
FROM authz_invitations
|
||||
WHERE organization_id = @organization_id
|
||||
AND %s
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, cursor.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"organization_id": orgID}
|
||||
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) CountByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
orgID gid.GID,
|
||||
) (int, error) {
|
||||
q := `
|
||||
SELECT
|
||||
COUNT(*)
|
||||
FROM
|
||||
authz_invitations
|
||||
WHERE
|
||||
organization_id = @organization_id
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{"organization_id": orgID}
|
||||
|
||||
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
|
||||
}
|
||||
73
pkg/coredata/invitation_order_field.go
Normal file
73
pkg/coredata/invitation_order_field.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// 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
|
||||
|
||||
import "fmt"
|
||||
|
||||
// InvitationOrderField defines the fields that can be used to order invitations
|
||||
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"
|
||||
)
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
func (e InvitationOrderField) IsValid() bool {
|
||||
switch e {
|
||||
case InvitationOrderFieldFullName, InvitationOrderFieldEmail, InvitationOrderFieldRole, InvitationOrderFieldCreatedAt, InvitationOrderFieldExpiresAt, InvitationOrderFieldAcceptedAt:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e InvitationOrderField) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *InvitationOrderField) UnmarshalText(text []byte) error {
|
||||
*e = InvitationOrderField(text)
|
||||
if !e.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid InvitationOrderField", string(text))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e InvitationOrderField) MarshalText() ([]byte, error) {
|
||||
return []byte(e.String()), nil
|
||||
}
|
||||
356
pkg/coredata/membership.go
Normal file
356
pkg/coredata/membership.go
Normal file
@@ -0,0 +1,356 @@
|
||||
// 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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/getprobo/probo/pkg/page"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"go.gearno.de/kit/pg"
|
||||
)
|
||||
|
||||
type (
|
||||
Membership struct {
|
||||
ID gid.GID `db:"id"`
|
||||
UserID gid.GID `db:"user_id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
Role string `db:"role"`
|
||||
FullName string `db:"full_name"`
|
||||
EmailAddress string `db:"email_address"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
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:
|
||||
return page.NewCursorKey(m.ID, m.FullName)
|
||||
case MembershipOrderFieldEmailAddress:
|
||||
return page.NewCursorKey(m.ID, m.EmailAddress)
|
||||
case MembershipOrderFieldRole:
|
||||
return page.NewCursorKey(m.ID, m.Role)
|
||||
case MembershipOrderFieldCreatedAt:
|
||||
return page.NewCursorKey(m.ID, m.CreatedAt)
|
||||
}
|
||||
|
||||
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because memberships are managed at the organization level and don't require tenant isolation.
|
||||
func (m *Membership) Create(ctx context.Context, conn pg.Conn) error {
|
||||
query := `
|
||||
INSERT INTO authz_memberships (id, user_id, organization_id, role, created_at, updated_at)
|
||||
SELECT
|
||||
generate_gid(decode_base64_unpadded(o.tenant_id), @entity_type),
|
||||
@user_id,
|
||||
@organization_id,
|
||||
@role,
|
||||
@created_at,
|
||||
@updated_at
|
||||
FROM organizations o
|
||||
WHERE o.id = @organization_id
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"user_id": m.UserID,
|
||||
"organization_id": m.OrganizationID,
|
||||
"role": m.Role,
|
||||
"created_at": m.CreatedAt,
|
||||
"updated_at": m.UpdatedAt,
|
||||
"entity_type": MembershipEntityType,
|
||||
}
|
||||
|
||||
result, err := conn.Exec(ctx, query, args)
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return ErrMembershipAlreadyExists{UserID: m.UserID, OrgID: m.OrganizationID}
|
||||
}
|
||||
return fmt.Errorf("failed to create membership: %w", err)
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
return fmt.Errorf("failed to create membership: organization %s not found", m.OrganizationID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because we want to access memberships across all tenants for authentication purposes.
|
||||
func (m *Membership) LoadByID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
membershipID gid.GID,
|
||||
) error {
|
||||
query := `
|
||||
SELECT
|
||||
m.id,
|
||||
m.user_id,
|
||||
m.organization_id,
|
||||
m.role,
|
||||
u.fullname as full_name,
|
||||
u.email_address,
|
||||
m.created_at,
|
||||
m.updated_at
|
||||
FROM authz_memberships m
|
||||
JOIN users u ON m.user_id = u.id
|
||||
WHERE m.id = @membership_id
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"membership_id": membershipID,
|
||||
}
|
||||
|
||||
rows, err := conn.Query(ctx, query, 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 ErrMembershipNotFound{UserID: gid.GID{}, OrgID: gid.GID{}}
|
||||
}
|
||||
return fmt.Errorf("cannot collect membership: %w", err)
|
||||
}
|
||||
|
||||
*m = membership
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because we want to access memberships across all tenants for authentication purposes.
|
||||
func (m *Membership) LoadByUserAndOrg(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
userID gid.GID,
|
||||
orgID gid.GID,
|
||||
) error {
|
||||
query := `
|
||||
SELECT
|
||||
m.id,
|
||||
m.user_id,
|
||||
m.organization_id,
|
||||
m.role,
|
||||
u.fullname as full_name,
|
||||
u.email_address,
|
||||
m.created_at,
|
||||
m.updated_at
|
||||
FROM authz_memberships m
|
||||
JOIN users u ON m.user_id = u.id
|
||||
WHERE m.user_id = @user_id AND m.organization_id = @organization_id
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"user_id": userID,
|
||||
"organization_id": orgID,
|
||||
}
|
||||
|
||||
rows, err := conn.Query(ctx, query, 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 ErrMembershipNotFound{UserID: userID, OrgID: orgID}
|
||||
}
|
||||
return fmt.Errorf("cannot collect membership: %w", err)
|
||||
}
|
||||
|
||||
*m = membership
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because memberships are managed at the organization level and don't require tenant isolation.
|
||||
func (m *Membership) Update(ctx context.Context, conn pg.Conn) error {
|
||||
query := `
|
||||
UPDATE authz_memberships
|
||||
SET role = @role, updated_at = @updated_at
|
||||
WHERE id = @id
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": m.ID,
|
||||
"role": m.Role,
|
||||
"updated_at": m.UpdatedAt,
|
||||
}
|
||||
|
||||
result, err := conn.Exec(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update membership: %w", err)
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
return ErrMembershipNotFound{UserID: m.UserID, OrgID: m.OrganizationID}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because memberships are managed at the organization level and don't require tenant isolation.
|
||||
func (m *Membership) Delete(ctx context.Context, conn pg.Conn) error {
|
||||
query := `
|
||||
DELETE FROM authz_memberships
|
||||
WHERE id = @id
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": m.ID,
|
||||
}
|
||||
|
||||
result, err := conn.Exec(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete membership: %w", err)
|
||||
}
|
||||
|
||||
if result.RowsAffected() == 0 {
|
||||
return ErrMembershipNotFound{UserID: m.UserID, OrgID: m.OrganizationID}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because we want to access all user's memberships across tenants for authentication purposes.
|
||||
func (m *Memberships) LoadByUserID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
userID gid.GID,
|
||||
) error {
|
||||
query := `
|
||||
SELECT
|
||||
m.id,
|
||||
m.user_id,
|
||||
m.organization_id,
|
||||
m.role,
|
||||
u.fullname as full_name,
|
||||
u.email_address,
|
||||
m.created_at,
|
||||
m.updated_at
|
||||
FROM
|
||||
authz_memberships m
|
||||
JOIN users u ON m.user_id = u.id
|
||||
WHERE
|
||||
m.user_id = @user_id
|
||||
ORDER BY
|
||||
m.created_at DESC
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{"user_id": userID}
|
||||
|
||||
rows, err := conn.Query(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query memberships: %w", err)
|
||||
}
|
||||
|
||||
memberships, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Membership])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect memberships: %w", err)
|
||||
}
|
||||
|
||||
*m = memberships
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because we want to access memberships across all tenants for authentication purposes.
|
||||
func (m *Memberships) LoadByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
organizationID gid.GID,
|
||||
cursor *page.Cursor[MembershipOrderField],
|
||||
) error {
|
||||
query := `
|
||||
SELECT
|
||||
m.id,
|
||||
m.user_id,
|
||||
m.organization_id,
|
||||
m.role,
|
||||
u.fullname as full_name,
|
||||
u.email_address,
|
||||
m.created_at,
|
||||
m.updated_at
|
||||
FROM
|
||||
authz_memberships m
|
||||
JOIN users u ON m.user_id = u.id
|
||||
WHERE
|
||||
m.organization_id = @organization_id
|
||||
AND %s
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, cursor.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||
maps.Copy(args, cursor.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query memberships: %w", err)
|
||||
}
|
||||
|
||||
memberships, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Membership])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect memberships: %w", err)
|
||||
}
|
||||
|
||||
*m = memberships
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memberships) CountByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
organizationID gid.GID,
|
||||
) (int, error) {
|
||||
query := `
|
||||
SELECT COUNT(*)
|
||||
FROM authz_memberships
|
||||
WHERE organization_id = @organization_id
|
||||
`
|
||||
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||
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
|
||||
}
|
||||
53
pkg/coredata/membership_order_field.go
Normal file
53
pkg/coredata/membership_order_field.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// 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 (
|
||||
MembershipOrderField string
|
||||
)
|
||||
|
||||
const (
|
||||
MembershipOrderFieldFullName MembershipOrderField = "FULL_NAME"
|
||||
MembershipOrderFieldEmailAddress MembershipOrderField = "EMAIL_ADDRESS"
|
||||
MembershipOrderFieldRole MembershipOrderField = "ROLE"
|
||||
MembershipOrderFieldCreatedAt MembershipOrderField = "CREATED_AT"
|
||||
)
|
||||
|
||||
func (p MembershipOrderField) Column() string {
|
||||
switch p {
|
||||
case MembershipOrderFieldFullName:
|
||||
return "u.fullname"
|
||||
case MembershipOrderFieldEmailAddress:
|
||||
return "u.email_address"
|
||||
case MembershipOrderFieldRole:
|
||||
return "m.role"
|
||||
case MembershipOrderFieldCreatedAt:
|
||||
return "m.created_at"
|
||||
}
|
||||
return string(p)
|
||||
}
|
||||
|
||||
func (p MembershipOrderField) String() string {
|
||||
return string(p)
|
||||
}
|
||||
|
||||
func (p MembershipOrderField) MarshalText() ([]byte, error) {
|
||||
return []byte(p.String()), nil
|
||||
}
|
||||
|
||||
func (p *MembershipOrderField) UnmarshalText(text []byte) error {
|
||||
*p = MembershipOrderField(text)
|
||||
return nil
|
||||
}
|
||||
41
pkg/coredata/migrations/20251006T220024Z.sql
Normal file
41
pkg/coredata/migrations/20251006T220024Z.sql
Normal file
@@ -0,0 +1,41 @@
|
||||
-- Create authorization tables for the new authz service
|
||||
-- This migration creates the new authz tables while keeping the existing users_organizations table
|
||||
-- for backward compatibility during the transition
|
||||
|
||||
-- Create role enum
|
||||
CREATE TYPE authz_role AS ENUM ('OWNER', 'ADMIN', 'MEMBER', 'VIEWER');
|
||||
|
||||
-- Create authz_memberships table with id as primary key
|
||||
CREATE TABLE authz_memberships (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
organization_id TEXT NOT NULL,
|
||||
role authz_role NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
UNIQUE (user_id, organization_id)
|
||||
);
|
||||
|
||||
-- Create authz_invitations table
|
||||
CREATE TABLE authz_invitations (
|
||||
id TEXT PRIMARY KEY,
|
||||
organization_id TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
full_name TEXT NOT NULL,
|
||||
role authz_role NOT NULL,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
accepted_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
-- Copy data from users_organizations to authz_memberships
|
||||
INSERT INTO authz_memberships (id, user_id, organization_id, role, created_at, updated_at)
|
||||
SELECT
|
||||
generate_gid(decode_base64_unpadded(organizations.tenant_id), 38) as id,
|
||||
users_organizations.user_id,
|
||||
users_organizations.organization_id,
|
||||
'MEMBER'::authz_role as role, -- Default role for existing memberships
|
||||
users_organizations.created_at,
|
||||
users_organizations.created_at as updated_at
|
||||
FROM users_organizations
|
||||
JOIN organizations ON users_organizations.organization_id = organizations.id;
|
||||
@@ -107,7 +107,7 @@ LIMIT 1;
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied in this functions because we want to access all user's organizations.
|
||||
func (o *Organizations) ListForUserID(
|
||||
func (o *Organizations) LoadByUserID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
userID gid.GID,
|
||||
@@ -118,7 +118,7 @@ WITH user_org AS (
|
||||
SELECT
|
||||
organization_id
|
||||
FROM
|
||||
users_organizations
|
||||
authz_memberships
|
||||
WHERE
|
||||
user_id = @user_id
|
||||
)
|
||||
@@ -163,6 +163,59 @@ WHERE
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied in this function because we want to access all user's organizations.
|
||||
func (o *Organizations) LoadAllByUserID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
userID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
WITH user_org AS (
|
||||
SELECT
|
||||
organization_id
|
||||
FROM
|
||||
authz_memberships
|
||||
WHERE
|
||||
user_id = @user_id
|
||||
)
|
||||
SELECT
|
||||
tenant_id,
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
website_url,
|
||||
email,
|
||||
headquarter_address,
|
||||
custom_domain_id,
|
||||
logo_file_id,
|
||||
horizontal_logo_file_id,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
organizations
|
||||
INNER JOIN
|
||||
user_org ON organizations.id = user_org.organization_id
|
||||
ORDER BY
|
||||
created_at DESC
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{"user_id": userID}
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query organizations: %w", err)
|
||||
}
|
||||
|
||||
organizations, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Organization])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect organizations: %w", err)
|
||||
}
|
||||
|
||||
*o = organizations
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *Organization) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
|
||||
@@ -47,6 +47,7 @@ func (s Session) CursorKey(orderBy SessionOrderField) page.CursorKey {
|
||||
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because we want to access sessions across all tenants for authentication purposes.
|
||||
func (s *Session) LoadByID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
|
||||
@@ -138,6 +138,7 @@ LIMIT 1;
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because we want to access trust centers by slug across all tenants for public access.
|
||||
func (tc *TrustCenter) LoadBySlug(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
|
||||
@@ -87,7 +87,7 @@ FROM
|
||||
users
|
||||
WHERE
|
||||
id IN (
|
||||
SELECT user_id FROM users_organizations WHERE organization_id = @organization_id
|
||||
SELECT user_id FROM authz_memberships WHERE organization_id = @organization_id
|
||||
)
|
||||
AND %s
|
||||
`
|
||||
@@ -112,6 +112,36 @@ WHERE
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *Users) CountByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
organizationID gid.GID,
|
||||
) (int, error) {
|
||||
q := `
|
||||
SELECT
|
||||
COUNT(*)
|
||||
FROM
|
||||
users
|
||||
WHERE
|
||||
id IN (
|
||||
SELECT user_id FROM authz_memberships WHERE organization_id = @organization_id
|
||||
)
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||
|
||||
row := conn.QueryRow(ctx, q, args)
|
||||
|
||||
var count int
|
||||
err := row.Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot count users: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because we want to access users across all tenants for authentication purposes.
|
||||
func (u *User) LoadByEmail(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
@@ -154,6 +184,7 @@ LIMIT 1;
|
||||
return nil
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because we want to access users across all tenants for authentication purposes.
|
||||
func (u *User) LoadByID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
|
||||
@@ -46,6 +46,7 @@ VALUES (@user_id, @organization_id, @created_at)
|
||||
return err
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because user organizations are managed at the organization level and don't require tenant isolation.
|
||||
func (uo UserOrganization) Delete(ctx context.Context, conn pg.Conn) error {
|
||||
q := `
|
||||
DELETE FROM users_organizations WHERE user_id = @user_id AND organization_id = @organization_id
|
||||
|
||||
Reference in New Issue
Block a user