// SPDX-License-Identifier: MPL-2.0 package authsqlite import ( "context" "errors" "gamertan.com/web/organizations" ) // CreateOwnedOrganization atomically creates a new organization and its first // direct owner. It never grants authority in an existing organization. func (store *Store) CreateOwnedOrganization(ctx context.Context, setup organizations.OwnedOrganization) error { organization, membership, binding := setup.Organization, setup.Membership, setup.OwnerBinding audit, accessAudit := setup.OrganizationAudit, setup.AccessAudit if !validOrganization(organization) || organization.Status != "active" || organization.Revision != 1 || membership.OrganizationID != organization.ID || !opaqueID(membership.UserID) || membership.Status != "active" || !membership.JoinedAt.Equal(organization.CreatedAt) || !validOwnerBinding(binding, organization.ID, membership.UserID) || !binding.GrantedAt.Equal(organization.CreatedAt) || !validOrganizationAudit(audit, organization.ID) || audit.ActorUserID != membership.UserID || audit.Action != "organization.create" || audit.ResourceType != "organization" || audit.ResourceID != organization.ID || !validAccessAudit(accessAudit) || accessAudit.OrganizationID != organization.ID || accessAudit.ActorUserID != membership.UserID || accessAudit.Action != "access.binding.grant" || accessAudit.ResourceType != "binding" || accessAudit.ResourceID != binding.ID || accessAudit.RequestID != audit.RequestID { return errors.New("authsqlite: invalid owned organization") } tx, err := store.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() var personalOwner any if organization.Personal { personalOwner = membership.UserID } // The first statement acquires the writer lock and validates active, completed // identity inside the transaction; account suspension cannot race the grant. result, err := tx.ExecContext(ctx, `INSERT INTO gwf_organizations(id,slug,name,personal,personal_owner_user_id,created_at,status,revision,updated_at) SELECT ?,?,?,?,?,?,?,?,? FROM gwf_users WHERE id=? AND status='active' AND registration_pending=0`, organization.ID, organization.Slug, organization.Name, organization.Personal, personalOwner, organization.CreatedAt.Unix(), organization.Status, organization.Revision, organization.UpdatedAt.Unix(), membership.UserID) if err != nil { return err } if changed, err := result.RowsAffected(); err != nil || changed != 1 { if err != nil { return err } return organizations.ErrOwnerAuthority } if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES(?,?,?,?)`, organization.ID, membership.UserID, membership.Status, membership.JoinedAt.Unix()); err != nil { return err } result, err = tx.ExecContext(ctx, `INSERT INTO gwf_access_bindings(id,organization_id,subject_kind,subject_id,role_name,project_id,environment_id,service_id,granted_by_user_id,granted_at) SELECT ?,?,'user',?,?,NULL,NULL,NULL,?,? FROM gwf_access_roles WHERE name=?`, binding.ID, organization.ID, membership.UserID, binding.Role, membership.UserID, binding.GrantedAt.Unix(), binding.Role) if err != nil { return err } if changed, err := result.RowsAffected(); err != nil || changed != 1 { if err != nil { return err } return errors.New("authsqlite: initial owner role has not been seeded") } if err = appendOrganizationAudit(ctx, tx, audit); err != nil { return err } if err = appendAccessAudit(ctx, tx, accessAudit); err != nil { return err } return tx.Commit() } var _ organizations.OwnedOrganizationRepository = (*Store)(nil)