Integrating with Existing Apps¶
This guide covers integrating SystemForge into an existing Go application that already has its own users, organizations, and memberships.
How SystemForge Models People¶
Every actor in SystemForge is a principal. A person is a principal of type human plus a one-to-one Human row with their email and profile. Passwords are credentials, organizations are organizations, and membership links a principal to an organization with a role:
existing tables SystemForge tables
───────────────── ──────────────────────────────────────
users ←──→ sf_principals (type = 'human') + sf_humans
sf_credentials (type = 'password')
organizations ←──→ sf_organizations
user_orgs ←──→ sf_principal_memberships
See the schema reference for every column.
Integration Patterns¶
Pattern 1: Side-by-Side Tables¶
Keep your existing tables and add the SystemForge tables alongside them, copying data across and switching reads over time. The migration guide covers the copy in detail.
Pattern 2: Extension Tables¶
Keep app-specific data in your own tables and reference the SystemForge principal by ID:
type UserProfile struct {
ent.Schema
}
func (UserProfile) Fields() []ent.Field {
return []ent.Field{
field.UUID("principal_id", uuid.UUID{}).Unique(),
field.String("username").Unique(),
field.String("bio").Optional(),
}
}
The principal ID is stable across every sign-in method (password, social login, federation, SCIM), so it is the right key for application data.
Step-by-Step Integration¶
Step 1: Add the Dependency¶
Step 2: Create the SystemForge Tables¶
Run systemauth migrate, or create the schema from your own code:
import (
"database/sql"
"entgo.io/ent/dialect"
entsql "entgo.io/ent/dialect/sql"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/plexusone/systemforge/identity/ent"
)
func migrate(ctx context.Context, dsn string) (*ent.Client, error) {
db, err := sql.Open("pgx", dsn)
if err != nil {
return nil, err
}
client := ent.NewClient(ent.Driver(entsql.OpenDB(dialect.Postgres, db)))
if err := client.Schema.Create(ctx); err != nil {
return nil, errors.Join(err, client.Close())
}
return client, nil
}
Step 3: Copy Existing Data¶
Copy users into sf_principals and sf_humans, compatible password hashes into sf_credentials, and then organizations and memberships. The migration guide has the SQL, including which password hash formats can be imported.
Step 4: Set Up Dual-Write¶
Create people through the principal service, which writes the principal and its human row together and uses the email as the principal's identifier. Then write your own row with the returned principal ID:
import (
"github.com/plexusone/systemforge/identity/credential"
"github.com/plexusone/systemforge/identity/principal"
)
type UserService struct {
appClient *appent.Client
principals principal.Service
creds credential.Service
}
func NewUserService(appClient *appent.Client, sfClient *ent.Client) *UserService {
return &UserService{
appClient: appClient,
principals: principal.NewService(sfClient),
creds: credential.NewService(sfClient),
}
}
func (s *UserService) CreateUser(ctx context.Context, email, name, password string) error {
p, err := s.principals.CreateHuman(ctx, principal.CreateHumanInput{
Email: email,
DisplayName: name,
})
if err != nil {
return err
}
if password != "" {
if err := s.creds.CreatePassword(ctx, credential.CreatePasswordInput{
PrincipalID: p.ID,
Password: password,
}); err != nil {
return err
}
}
// Write to app tables with the same ID.
_, err = s.appClient.User.Create().
SetID(p.ID).
SetEmail(email).
SetName(name).
Save(ctx)
return err
}
CreatePassword hashes with Argon2id, and credential.Service.VerifyPassword checks a password against it.
Step 5: Switch Reads¶
Read people from SystemForge. GetByID returns the principal with its human extension populated in Human:
func (s *UserService) GetUser(ctx context.Context, id uuid.UUID) (*User, error) {
p, err := s.principals.GetByID(ctx, id)
if err != nil {
return nil, err
}
profile, err := s.appClient.UserProfile.Query().
Where(userprofile.PrincipalIDEQ(id)).
Only(ctx)
if err != nil && !appent.IsNotFound(err) {
return nil, err
}
return &User{Principal: p, Profile: profile}, nil
}
To find someone by email, query the human row without regard to case, since SystemForge treats emails case-insensitively:
p, err := sfClient.Principal.Query().
Where(
principal.TypeEQ(principal.TypeHuman),
principal.HasHumanWith(human.EmailEqualFold(email)),
).
WithHuman().
Only(ctx)
This query uses the Ent packages identity/ent/principal and identity/ent/human.
Step 6: Remove Old Tables¶
After validation, archive and drop the old user tables:
OAuth Integration¶
SystemForge's OAuth 2.0 and OpenID Connect server is SystemAuth. An existing application has two supported ways to use it:
- Run SystemAuth as an identity provider and federate to it. The application becomes an OpenID Connect client with the
identity/relyingpartypackage, which handles the authorization flow, token verification, and finding or creating the application's local record for each principal. See Relying Parties. - Embed SystemAuth in the application's binary. The
identity/systemauthsvcpackage runs the same server as the standalonesystemauthbinary inside your process, with the same configuration, production checks, and database setup. See Embedding SystemAuth.
Either way, the subject of every token is the SystemForge principal ID, so it can be used directly as the key into your extension tables.
Multi-Tenant Integration¶
Map Existing Tenants¶
func copyOrganizations(ctx context.Context, appClient *appent.Client, sfClient *ent.Client) error {
tenants, err := appClient.Tenant.Query().All(ctx)
if err != nil {
return err
}
for _, t := range tenants {
if _, err := sfClient.Organization.Create().
SetID(t.ID).
SetName(t.Name).
SetSlug(t.Slug).
Save(ctx); err != nil {
return err
}
}
return nil
}
Organizations have no plan; carry a tenant's paid tier over as a marketplace subscription or license.
Map Existing Memberships¶
func copyMemberships(ctx context.Context, appClient *appent.Client, sfClient *ent.Client) error {
members, err := appClient.TenantMember.Query().All(ctx)
if err != nil {
return err
}
for _, m := range members {
if _, err := sfClient.PrincipalMembership.Create().
SetPrincipalID(m.UserID).
SetOrganizationID(m.TenantID).
SetRole(mapRole(m.Role)).
Save(ctx); err != nil {
return err
}
}
return nil
}
role is an application-defined string; PrincipalMembership also has an active flag (default true) for deactivating a membership without deleting it.
Validation Checklist¶
- [ ] SystemForge tables created (
sf_*) - [ ] People copied to
sf_principalsandsf_humans - [ ] Compatible password hashes copied to
sf_credentials; other users told to reset - [ ] Organizations and memberships copied
- [ ] Dual-write enabled
- [ ] Read paths switched
- [ ] Sign-in through SystemAuth working, with token subjects mapped to principal IDs
- [ ] Old tables archived
- [ ] Dual-write disabled
- [ ] Old tables dropped