Add multi-tenant system

Signed-off-by: gearnode <bryan@frimin.fr>
This commit is contained in:
gearnode
2025-02-25 22:52:06 +01:00
parent 29ce55e89c
commit 0c176674a0
35 changed files with 1254 additions and 686 deletions

View File

@@ -345,9 +345,10 @@ type EvidenceStateTransition {
# Authentication types
type User {
id: ID!
fullName: String!
email: String!
organization: Organization! @goField(forceResolver: true)
organizations: [Organization!]! @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!

View File

@@ -271,11 +271,12 @@ type ComplexityRoot struct {
}
User struct {
CreatedAt func(childComplexity int) int
Email func(childComplexity int) int
ID func(childComplexity int) int
Organization func(childComplexity int) int
UpdatedAt func(childComplexity int) int
CreatedAt func(childComplexity int) int
Email func(childComplexity int) int
FullName func(childComplexity int) int
ID func(childComplexity int) int
Organizations func(childComplexity int) int
UpdatedAt func(childComplexity int) int
}
Vendor struct {
@@ -337,7 +338,7 @@ type TaskResolver interface {
Evidences(ctx context.Context, obj *types.Task, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.EvidenceConnection, error)
}
type UserResolver interface {
Organization(ctx context.Context, obj *types.User) (*types.Organization, error)
Organizations(ctx context.Context, obj *types.User) ([]*types.Organization, error)
}
type executableSchema struct {
@@ -1258,6 +1259,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.User.Email(childComplexity), true
case "User.fullName":
if e.complexity.User.FullName == nil {
break
}
return e.complexity.User.FullName(childComplexity), true
case "User.id":
if e.complexity.User.ID == nil {
break
@@ -1265,12 +1273,12 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.User.ID(childComplexity), true
case "User.organization":
if e.complexity.User.Organization == nil {
case "User.organizations":
if e.complexity.User.Organizations == nil {
break
}
return e.complexity.User.Organization(childComplexity), true
return e.complexity.User.Organizations(childComplexity), true
case "User.updatedAt":
if e.complexity.User.UpdatedAt == nil {
@@ -1856,9 +1864,10 @@ type EvidenceStateTransition {
# Authentication types
type User {
id: ID!
fullName: String!
email: String!
organization: Organization! @goField(forceResolver: true)
organizations: [Organization!]! @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
@@ -6740,10 +6749,12 @@ func (ec *executionContext) fieldContext_Query_viewer(_ context.Context, field g
switch field.Name {
case "id":
return ec.fieldContext_User_id(ctx, field)
case "fullName":
return ec.fieldContext_User_fullName(ctx, field)
case "email":
return ec.fieldContext_User_email(ctx, field)
case "organization":
return ec.fieldContext_User_organization(ctx, field)
case "organizations":
return ec.fieldContext_User_organizations(ctx, field)
case "createdAt":
return ec.fieldContext_User_createdAt(ctx, field)
case "updatedAt":
@@ -7896,6 +7907,44 @@ func (ec *executionContext) fieldContext_User_id(_ context.Context, field graphq
return fc, nil
}
func (ec *executionContext) _User_fullName(ctx context.Context, field graphql.CollectedField, obj *types.User) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_User_fullName(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
ctx = rctx // use context from middleware stack in children
return obj.FullName, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(string)
fc.Result = res
return ec.marshalNString2string(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_User_fullName(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "User",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type String does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _User_email(ctx context.Context, field graphql.CollectedField, obj *types.User) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_User_email(ctx, field)
if err != nil {
@@ -7934,15 +7983,15 @@ func (ec *executionContext) fieldContext_User_email(_ context.Context, field gra
return fc, nil
}
func (ec *executionContext) _User_organization(ctx context.Context, field graphql.CollectedField, obj *types.User) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_User_organization(ctx, field)
func (ec *executionContext) _User_organizations(ctx context.Context, field graphql.CollectedField, obj *types.User) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_User_organizations(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
ctx = rctx // use context from middleware stack in children
return ec.resolvers.User().Organization(rctx, obj)
return ec.resolvers.User().Organizations(rctx, obj)
})
if err != nil {
ec.Error(ctx, err)
@@ -7954,12 +8003,12 @@ func (ec *executionContext) _User_organization(ctx context.Context, field graphq
}
return graphql.Null
}
res := resTmp.(*types.Organization)
res := resTmp.([]*types.Organization)
fc.Result = res
return ec.marshalNOrganization2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐOrganization(ctx, field.Selections, res)
return ec.marshalNOrganization2ᚕᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐOrganizationᚄ(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_User_organization(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
func (ec *executionContext) fieldContext_User_organizations(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "User",
Field: field,
@@ -12702,16 +12751,21 @@ func (ec *executionContext) _User(ctx context.Context, sel ast.SelectionSet, obj
if out.Values[i] == graphql.Null {
atomic.AddUint32(&out.Invalids, 1)
}
case "fullName":
out.Values[i] = ec._User_fullName(ctx, field, obj)
if out.Values[i] == graphql.Null {
atomic.AddUint32(&out.Invalids, 1)
}
case "email":
out.Values[i] = ec._User_email(ctx, field, obj)
if out.Values[i] == graphql.Null {
atomic.AddUint32(&out.Invalids, 1)
}
case "organization":
case "organizations":
field := field
innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) {
res = ec._User_organization(ctx, field, obj)
res = ec._User_organizations(ctx, field, obj)
if res == graphql.Null {
atomic.AddUint32(&fs.Invalids, 1)
}
@@ -13853,8 +13907,42 @@ func (ec *executionContext) marshalNNode2githubᚗcomᚋgetproboᚋproboᚋpkg
return ec._Node(ctx, sel, v)
}
func (ec *executionContext) marshalNOrganization2githubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐOrganization(ctx context.Context, sel ast.SelectionSet, v types.Organization) graphql.Marshaler {
return ec._Organization(ctx, sel, &v)
func (ec *executionContext) marshalNOrganization2ᚕᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐOrganizationᚄ(ctx context.Context, sel ast.SelectionSet, v []*types.Organization) graphql.Marshaler {
ret := make(graphql.Array, len(v))
var wg sync.WaitGroup
isLen1 := len(v) == 1
if !isLen1 {
wg.Add(len(v))
}
for i := range v {
i := i
fc := &graphql.FieldContext{
Index: &i,
Result: &v[i],
}
ctx := graphql.WithFieldContext(ctx, fc)
f := func(i int) {
if !isLen1 {
defer wg.Done()
}
ret[i] = ec.marshalNOrganization2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐOrganization(ctx, sel, v[i])
}
if isLen1 {
f(i)
} else {
go f(i)
}
}
wg.Wait()
for _, e := range ret {
if e == graphql.Null {
return graphql.Null
}
}
return ret
}
func (ec *executionContext) marshalNOrganization2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐOrganization(ctx context.Context, sel ast.SelectionSet, v *types.Organization) graphql.Marshaler {

View File

@@ -292,11 +292,12 @@ type UpdateVendorInput struct {
}
type User struct {
ID gid.GID `json:"id"`
Email string `json:"email"`
Organization *Organization `json:"organization"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID gid.GID `json:"id"`
FullName string `json:"fullName"`
Email string `json:"email"`
Organizations []*Organization `json:"organizations"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type Vendor struct {

View File

@@ -0,0 +1,49 @@
// 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 types
import (
"time"
"github.com/getprobo/probo/pkg/usrmgr/coredata"
)
type UserType struct {
ID string
Email string
CreatedAt time.Time
UpdatedAt time.Time
}
// NewUser creates a new UserType from a coredata.User
func NewUser(u *coredata.User) *UserType {
return &UserType{
ID: u.ID.String(),
Email: u.EmailAddress,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
}
}
// AddUserToOrganizationInput is the input for adding a user to an organization
type AddUserToOrganizationInput struct {
UserID string
OrganizationID string
}
// AddUserToOrganizationPayload is the payload for adding a user to an organization
type AddUserToOrganizationPayload struct {
User *UserType
}

View File

@@ -269,6 +269,7 @@ func (r *queryResolver) Viewer(ctx context.Context) (*types.User, error) {
return &types.User{
ID: user.ID,
Email: user.EmailAddress,
FullName: user.FullName,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
}, nil
@@ -298,26 +299,30 @@ func (r *taskResolver) Evidences(ctx context.Context, obj *types.Task, first *in
return types.NewEvidenceConnection(page), nil
}
// Organization is the resolver for the organization field.
func (r *userResolver) Organization(ctx context.Context, obj *types.User) (*types.Organization, error) {
// Get the user's organization ID
organizationID, err := r.usrmgrSvc.GetUserOrganization(ctx, obj.ID)
// Organizations is the resolver for the organizations field.
func (r *userResolver) Organizations(ctx context.Context, obj *types.User) ([]*types.Organization, error) {
// Get the user's organization IDs
organizationIDs, err := r.usrmgrSvc.GetUserOrganizations(ctx, obj.ID)
if err != nil {
return nil, fmt.Errorf("failed to get user organization: %w", err)
return nil, fmt.Errorf("failed to get user organizations: %w", err)
}
// If the user doesn't have an organization, return nil
if organizationID == gid.Nil {
return nil, nil
// If the user doesn't have any organizations, return an empty slice
if len(organizationIDs) == 0 {
return []*types.Organization{}, nil
}
// Get the organization details
organization, err := r.proboSvc.GetOrganization(ctx, organizationID)
if err != nil {
return nil, fmt.Errorf("failed to get organization details: %w", err)
// Get the organization details for each organization ID
var organizations []*types.Organization
for _, organizationID := range organizationIDs {
organization, err := r.proboSvc.GetOrganization(ctx, organizationID)
if err != nil {
return nil, fmt.Errorf("failed to get organization details: %w", err)
}
organizations = append(organizations, types.NewOrganization(organization))
}
return types.NewOrganization(organization), nil
return organizations, nil
}
// Control returns schema.ControlResolver implementation.

View File

@@ -0,0 +1,11 @@
CREATE TABLE usrmgr_user_organizations (
user_id TEXT REFERENCES usrmgr_users(id) NOT NULL,
organization_id TEXT REFERENCES organizations(id) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, organization_id)
);
INSERT INTO usrmgr_user_organizations (user_id, organization_id, created_at)
SELECT id, organization_id, NOW()
FROM usrmgr_users
WHERE organization_id IS NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE usrmgr_users ADD COLUMN fullname TEXT;

View File

@@ -0,0 +1 @@
ALTER TABLE usrmgr_users ALTER COLUMN fullname SET NOT NULL;

View File

@@ -29,6 +29,7 @@ type (
ID gid.GID
EmailAddress string
HashedPassword []byte
FullName string
OrganizationID gid.GID
CreatedAt time.Time
UpdatedAt time.Time
@@ -44,6 +45,7 @@ func (u *User) scan(r pgx.Row) error {
&u.ID,
&u.EmailAddress,
&u.HashedPassword,
&u.FullName,
&u.OrganizationID,
&u.CreatedAt,
&u.UpdatedAt,
@@ -60,6 +62,7 @@ SELECT
id,
email_address,
hashed_password,
fullname,
organization_id,
created_at,
updated_at
@@ -94,6 +97,7 @@ SELECT
id,
email_address,
hashed_password,
fullname,
organization_id,
created_at,
updated_at
@@ -124,11 +128,12 @@ func (u *User) Insert(
) error {
q := `
INSERT INTO
usrmgr_users (id, email_address, hashed_password, organization_id, created_at, updated_at)
usrmgr_users (id, email_address, hashed_password, fullname, organization_id, created_at, updated_at)
VALUES (
@user_id,
@email_address,
@hashed_password,
@fullname,
@organization_id,
@created_at,
@updated_at
@@ -139,6 +144,7 @@ VALUES (
"user_id": u.ID,
"email_address": u.EmailAddress,
"hashed_password": u.HashedPassword,
"fullname": u.FullName,
"organization_id": "AZSfP_xAcAC5IAAAAAAltA",
"created_at": u.CreatedAt,
"updated_at": u.UpdatedAt,

View File

@@ -308,46 +308,6 @@ func (s Service) GetUserBySession(
return s.GetUserByID(ctx, session.UserID)
}
// SetUserOrganization sets the organization for a user
func (s Service) SetUserOrganization(
ctx context.Context,
userID gid.GID,
organizationID gid.GID,
) error {
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
user := &coredata.User{}
if err := user.LoadByID(ctx, tx, userID); err != nil {
return fmt.Errorf("user not found: %w", err)
}
// Update the organization ID
user.OrganizationID = organizationID
user.UpdatedAt = time.Now()
// Update the user in the database
q := `
UPDATE usrmgr_users
SET organization_id = @organization_id, updated_at = @updated_at
WHERE id = @user_id
`
args := pgx.NamedArgs{
"user_id": user.ID,
"organization_id": user.OrganizationID,
"updated_at": user.UpdatedAt,
}
_, err := tx.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot update user organization: %w", err)
}
return nil
},
)
}
// GetUserOrganization gets the organization ID for a user
func (s Service) GetUserOrganization(
ctx context.Context,
@@ -360,3 +320,101 @@ func (s Service) GetUserOrganization(
return user.OrganizationID, nil
}
// GetUserOrganizations gets all organizations for a user
func (s Service) GetUserOrganizations(
ctx context.Context,
userID gid.GID,
) ([]gid.GID, error) {
q := `
SELECT
organization_id
FROM
usrmgr_user_organizations
WHERE
user_id = @user_id;
`
args := pgx.NamedArgs{"user_id": userID}
var organizationIDs []gid.GID
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
rows, err := tx.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("failed to query user organizations: %w", err)
}
defer rows.Close()
for rows.Next() {
var organizationID gid.GID
if err := rows.Scan(&organizationID); err != nil {
return fmt.Errorf("failed to scan organization ID: %w", err)
}
organizationIDs = append(organizationIDs, organizationID)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("error iterating over rows: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return organizationIDs, nil
}
// AddUserToOrganization adds a user to an organization
func (s Service) AddUserToOrganization(
ctx context.Context,
userID gid.GID,
organizationID gid.GID,
) error {
q := `
INSERT INTO
usrmgr_user_organizations (user_id, organization_id, created_at)
VALUES
(@user_id, @organization_id, NOW())
ON CONFLICT (user_id, organization_id) DO NOTHING;
`
args := pgx.NamedArgs{
"user_id": userID,
"organization_id": organizationID,
}
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
_, err := tx.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("failed to add user to organization: %w", err)
}
return nil
},
)
}
// GetUserIDFromContext gets the user ID from the context
func (s Service) GetUserIDFromContext(ctx context.Context) (gid.GID, error) {
// Get the session ID from the context
sessionID, ok := ctx.Value("session_id").(gid.GID)
if !ok {
return gid.GID{}, fmt.Errorf("no session ID in context")
}
// Get the session
session, err := s.GetSession(ctx, sessionID)
if err != nil {
return gid.GID{}, fmt.Errorf("failed to get session: %w", err)
}
return session.UserID, nil
}