diff --git a/pkg/coredata/migrations/20251219T220314Z.sql b/pkg/coredata/migrations/20251219T220314Z.sql new file mode 100644 index 000000000..38c9a4062 --- /dev/null +++ b/pkg/coredata/migrations/20251219T220314Z.sql @@ -0,0 +1,5 @@ +ALTER TABLE auth_saml_configurations DROP COLUMN enabled; +ALTER TABLE auth_saml_configurations DROP COLUMN domain_verified; + +CREATE UNIQUE INDEX idx_saml_config_domain_org_unique + ON auth_saml_configurations(organization_id, email_domain); diff --git a/pkg/coredata/migrations/20251219T230719Z.sql b/pkg/coredata/migrations/20251219T230719Z.sql new file mode 100644 index 000000000..b4edbe446 --- /dev/null +++ b/pkg/coredata/migrations/20251219T230719Z.sql @@ -0,0 +1,3 @@ +CREATE UNIQUE INDEX idx_saml_config_domain_verification_token_unique + ON auth_saml_configurations(domain_verification_token); + diff --git a/pkg/coredata/saml_configuration.go b/pkg/coredata/saml_configuration.go index 25b9c8587..714bdf033 100644 --- a/pkg/coredata/saml_configuration.go +++ b/pkg/coredata/saml_configuration.go @@ -43,7 +43,6 @@ type ( AttributeLastname string `db:"attribute_lastname"` AttributeRole string `db:"attribute_role"` AutoSignupEnabled bool `db:"auto_signup_enabled"` - DomainVerified bool `db:"domain_verified"` DomainVerificationToken *string `db:"domain_verification_token"` DomainVerifiedAt *time.Time `db:"domain_verified_at"` CreatedAt time.Time `db:"created_at"` @@ -88,7 +87,6 @@ SELECT id, organization_id, email_domain, - enabled, enforcement_policy, idp_entity_id, idp_sso_url, @@ -99,7 +97,6 @@ SELECT attribute_lastname, attribute_role, auto_signup_enabled, - domain_verified, domain_verification_token, domain_verified_at, created_at, @@ -147,7 +144,6 @@ SELECT id, organization_id, email_domain, - enabled, enforcement_policy, idp_entity_id, idp_sso_url, @@ -158,7 +154,6 @@ SELECT attribute_lastname, attribute_role, auto_signup_enabled, - domain_verified, domain_verification_token, domain_verified_at, created_at, @@ -195,6 +190,58 @@ LIMIT 1; return nil } +func (s *SAMLConfiguration) LoadByIDForUpdateSkipLocked( + ctx context.Context, + conn pg.Conn, + configID gid.GID, +) error { + q := ` +SELECT + id, + organization_id, + email_domain, + enforcement_policy, + idp_entity_id, + idp_sso_url, + idp_certificate, + idp_metadata_url, + attribute_email, + attribute_firstname, + attribute_lastname, + attribute_role, + auto_signup_enabled, + domain_verification_token, + domain_verified_at, + created_at, + updated_at +FROM + auth_saml_configurations +WHERE + id = @id +FOR UPDATE SKIP LOCKED; +` + + args := pgx.StrictNamedArgs{"id": configID} + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query auth_saml_configurations: %w", err) + } + + config, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[SAMLConfiguration]) + if err != nil { + if err == pgx.ErrNoRows { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect saml_configuration: %w", err) + } + + *s = config + + return nil +} + func (s *SAMLConfiguration) Insert( ctx context.Context, conn pg.Conn, @@ -216,7 +263,6 @@ INSERT INTO auth_saml_configurations ( attribute_lastname, attribute_role, auto_signup_enabled, - domain_verified, domain_verification_token, domain_verified_at, created_at, @@ -236,7 +282,6 @@ INSERT INTO auth_saml_configurations ( @attribute_lastname, @attribute_role, @auto_signup_enabled, - @domain_verified, @domain_verification_token, @domain_verified_at, @created_at, @@ -259,7 +304,6 @@ INSERT INTO auth_saml_configurations ( "attribute_lastname": s.AttributeLastname, "attribute_role": s.AttributeRole, "auto_signup_enabled": s.AutoSignupEnabled, - "domain_verified": s.DomainVerified, "domain_verification_token": s.DomainVerificationToken, "domain_verified_at": s.DomainVerifiedAt, "created_at": s.CreatedAt, @@ -292,7 +336,6 @@ SET attribute_lastname = @attribute_lastname, attribute_role = @attribute_role, auto_signup_enabled = @auto_signup_enabled, - domain_verified = @domain_verified, domain_verification_token = @domain_verification_token, domain_verified_at = @domain_verified_at, updated_at = @updated_at @@ -315,7 +358,6 @@ WHERE "attribute_lastname": s.AttributeLastname, "attribute_role": s.AttributeRole, "auto_signup_enabled": s.AutoSignupEnabled, - "domain_verified": s.DomainVerified, "domain_verification_token": s.DomainVerificationToken, "domain_verified_at": s.DomainVerifiedAt, "updated_at": s.UpdatedAt, @@ -377,7 +419,6 @@ SELECT attribute_lastname, attribute_role, auto_signup_enabled, - domain_verified, domain_verification_token, domain_verified_at, created_at, @@ -410,122 +451,6 @@ ORDER BY email_domain ASC; return nil } -// LoadAllEnabledSAMLConfigurationsByEmailDomain loads all enabled SAML configurations for a given email domain -// This is used for SSO login detection when multiple organizations may have SAML configured for the same domain -func LoadAllEnabledSAMLConfigurationsByEmailDomain( - ctx context.Context, - conn pg.Conn, - emailDomain string, -) ([]*SAMLConfiguration, error) { - q := ` -SELECT - id, - organization_id, - email_domain, - enforcement_policy, - idp_entity_id, - idp_sso_url, - idp_certificate, - idp_metadata_url, - attribute_email, - attribute_firstname, - attribute_lastname, - attribute_role, - auto_signup_enabled, - domain_verified, - domain_verification_token, - domain_verified_at, - created_at, - updated_at -FROM - auth_saml_configurations -WHERE - email_domain = $1 - AND enabled = true - AND domain_verified = true -ORDER BY created_at ASC; -` - - rows, err := conn.Query(ctx, q, emailDomain) - if err != nil { - return nil, fmt.Errorf("cannot query auth_saml_configurations: %w", err) - } - - configs, err := pgx.CollectRows(rows, pgx.RowToStructByName[SAMLConfiguration]) - if err != nil { - return nil, fmt.Errorf("cannot collect saml_configurations: %w", err) - } - - result := make([]*SAMLConfiguration, len(configs)) - for i := range configs { - result[i] = &configs[i] - } - - return result, nil -} - -// LoadSAMLConfigurationsByOrganizationIDsAndEmailDomain loads SAML configurations for multiple organizations -// and a given email domain in a single query. This is used to avoid N+1 queries. -func LoadSAMLConfigurationsByOrganizationIDsAndEmailDomain( - ctx context.Context, - conn pg.Conn, - organizationIDs []gid.GID, - emailDomain string, -) (map[gid.GID]*SAMLConfiguration, error) { - if len(organizationIDs) == 0 { - return make(map[gid.GID]*SAMLConfiguration), nil - } - - q := ` -SELECT - id, - organization_id, - email_domain, - enforcement_policy, - idp_entity_id, - idp_sso_url, - idp_certificate, - idp_metadata_url, - attribute_email, - attribute_firstname, - attribute_lastname, - attribute_role, - auto_signup_enabled, - domain_verified, - domain_verification_token, - domain_verified_at, - created_at, - updated_at -FROM - auth_saml_configurations -WHERE - organization_id = ANY(@organization_ids) - AND email_domain = @email_domain -` - - args := pgx.StrictNamedArgs{ - "organization_ids": organizationIDs, - "email_domain": emailDomain, - } - - rows, err := conn.Query(ctx, q, args) - if err != nil { - return nil, fmt.Errorf("cannot query auth_saml_configurations: %w", err) - } - - configs, err := pgx.CollectRows(rows, pgx.RowToStructByName[SAMLConfiguration]) - if err != nil { - return nil, fmt.Errorf("cannot collect saml_configurations: %w", err) - } - - result := make(map[gid.GID]*SAMLConfiguration, len(configs)) - for i := range configs { - result[configs[i].OrganizationID] = &configs[i] - } - - return result, nil -} - func (s *SAMLConfigurations) CountByOrganizationID( ctx context.Context, conn pg.Conn, @@ -560,3 +485,50 @@ WHERE return count, nil } + +func (s *SAMLConfigurations) LoadUnverified( + ctx context.Context, + conn pg.Conn, +) error { + q := ` +SELECT + id, + organization_id, + email_domain, + enforcement_policy, + idp_entity_id, + idp_sso_url, + idp_certificate, + idp_metadata_url, + attribute_email, + attribute_firstname, + attribute_lastname, + attribute_role, + auto_signup_enabled, + domain_verification_token, + domain_verified_at, + created_at, + updated_at +FROM + auth_saml_configurations +WHERE + domain_verified_at IS NULL + AND domain_verification_token IS NOT NULL +ORDER BY created_at ASC +LIMIT 100; +` + + rows, err := conn.Query(ctx, q) + if err != nil { + return fmt.Errorf("cannot query unverified auth_saml_configurations: %w", err) + } + + configs, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[SAMLConfiguration]) + if err != nil { + return fmt.Errorf("cannot collect unverified saml_configurations: %w", err) + } + + *s = configs + + return nil +} diff --git a/pkg/iam/organization_service.go b/pkg/iam/organization_service.go index 1465a779e..85768a5f4 100644 --- a/pkg/iam/organization_service.go +++ b/pkg/iam/organization_service.go @@ -970,26 +970,26 @@ func (s OrganizationService) CreateSAMLConfiguration( req *CreateSAMLConfigurationRequest, ) (*coredata.SAMLConfiguration, error) { var ( - now = time.Now() - scope = coredata.NewScopeFromObjectID(organizationID) - config = &coredata.SAMLConfiguration{ - ID: gid.New(scope.GetTenantID(), coredata.SAMLConfigurationEntityType), - OrganizationID: organizationID, - EnforcementPolicy: coredata.SAMLEnforcementPolicyOff, - IdPEntityID: req.IdPEntityID, - IdPSsoURL: req.IdPSsoURL, - IdPCertificate: req.IdPCertificate, - EmailDomain: req.EmailDomain, - AutoSignupEnabled: req.AutoSignupEnabled, - AttributeEmail: DefaultAttributeEmail, - AttributeFirstname: DefaultAttributeFirstname, - AttributeLastname: DefaultAttributeLastname, - AttributeRole: DefaultAttributeRole, - CreatedAt: now, - UpdatedAt: now, + now = time.Now() + scope = coredata.NewScopeFromObjectID(organizationID) + domainVerificationToken = uuid.MustNewV4().String() + config = &coredata.SAMLConfiguration{ + ID: gid.New(scope.GetTenantID(), coredata.SAMLConfigurationEntityType), + OrganizationID: organizationID, + EnforcementPolicy: coredata.SAMLEnforcementPolicyOff, + IdPEntityID: req.IdPEntityID, + IdPSsoURL: req.IdPSsoURL, + IdPCertificate: req.IdPCertificate, + DomainVerificationToken: &domainVerificationToken, + EmailDomain: req.EmailDomain, + AutoSignupEnabled: req.AutoSignupEnabled, + AttributeEmail: DefaultAttributeEmail, + AttributeFirstname: DefaultAttributeFirstname, + AttributeLastname: DefaultAttributeLastname, + AttributeRole: DefaultAttributeRole, + CreatedAt: now, + UpdatedAt: now, } - - // TODO create domain verification object ) if req.AttributeEmail != nil { @@ -1059,7 +1059,7 @@ func (s OrganizationService) UpdateSAMLConfiguration( } if req.EnforcementPolicy != nil { - if !config.DomainVerified { + if config.DomainVerifiedAt == nil { return NewSAMLConfigurationDomainNotVerifiedError(configID) } diff --git a/pkg/iam/saml_config_validator.go b/pkg/iam/saml_config_validator.go deleted file mode 100644 index 079f8123f..000000000 --- a/pkg/iam/saml_config_validator.go +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (c) 2025 Probo Inc . -// -// 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 iam - -import ( - "crypto/x509" - "encoding/pem" - "fmt" - "net/url" - - pemutil "go.probo.inc/probo/pkg/crypto/pem" -) - -// ValidateIdPConfiguration validates only the IdP (Identity Provider) configuration. -// This validates user-provided data from the IdP. -// SP (Service Provider) configuration is generated by the application and doesn't need validation. -func ValidateIdPConfiguration( - idpEntityID string, - idpSsoURL string, - idpCertificate string, -) error { - // Validate IdP Entity ID - if idpEntityID == "" { - return fmt.Errorf("IdP Entity ID cannot be empty") - } - - // Validate IdP SSO URL - accept both HTTP and HTTPS - if err := validateURL(idpSsoURL); err != nil { - return err - } - - // Validate IdP certificate - if err := validateCertificate(idpCertificate); err != nil { - return err - } - - return nil -} - -func validateURL(urlStr string) error { - if urlStr == "" { - return fmt.Errorf("URL cannot be empty") - } - - parsedURL, err := url.Parse(urlStr) - if err != nil { - return fmt.Errorf("invalid URL format: %w", err) - } - - if parsedURL.Scheme == "" { - return fmt.Errorf("URL must have a scheme (http or https)") - } - - if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { - return fmt.Errorf("URL scheme must be http or https (found: %s)", parsedURL.Scheme) - } - - if parsedURL.Host == "" { - return fmt.Errorf("URL must have a host") - } - - return nil -} - -func validateCertificate(certPEM string) error { - if certPEM == "" { - return fmt.Errorf("certificate cannot be empty") - } - - block, _ := pem.Decode([]byte(certPEM)) - if block == nil { - return fmt.Errorf("cannot parse certificate PEM") - } - - if block.Type != pemutil.BlockTypeCertificate { - return fmt.Errorf("PEM block type must be CERTIFICATE (found: %s)", block.Type) - } - - _, err := x509.ParseCertificate(block.Bytes) - if err != nil { - return fmt.Errorf("cannot parse X.509 certificate: %w", err) - } - - return nil -} diff --git a/pkg/iam/saml_domain_verifier.go b/pkg/iam/saml_domain_verifier.go new file mode 100644 index 000000000..f836d0af8 --- /dev/null +++ b/pkg/iam/saml_domain_verifier.go @@ -0,0 +1,206 @@ +// Copyright (c) 2025 Probo Inc . +// +// 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 iam + +import ( + "context" + "fmt" + "strings" + "time" + + "codeberg.org/miekg/dns" + "go.gearno.de/kit/log" + "go.gearno.de/kit/pg" + "go.opentelemetry.io/otel/trace" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" +) + +type ( + SAMLDomainVerifier struct { + pg *pg.Client + interval time.Duration + resolverAddr string + logger *log.Logger + tracer trace.Tracer + } +) + +const ( + txtRecordValuePrefix = "probo-verification=" +) + +func NewSAMLDomainVerifier( + pgClient *pg.Client, + logger *log.Logger, + tp trace.TracerProvider, + interval time.Duration, + resolverAddr string, +) *SAMLDomainVerifier { + return &SAMLDomainVerifier{ + pg: pgClient, + interval: interval, + resolverAddr: resolverAddr, + logger: logger.Named("saml-domain-verifier"), + tracer: tp.Tracer("go.probo.inc/probo/pkg/iam/saml_domain_verifier"), + } +} + +func (v *SAMLDomainVerifier) Run(ctx context.Context) error { + v.logger.InfoCtx(ctx, "starting", log.Duration("interval", v.interval)) + + for { + v.runOnce(ctx) + + select { + case <-ctx.Done(): + v.logger.InfoCtx(ctx, "shutting down") + return ctx.Err() + case <-time.After(v.interval): + } + } +} + +func (v *SAMLDomainVerifier) runOnce(ctx context.Context) { + ctx, span := v.tracer.Start(ctx, "SAMLDomainVerifier.runOnce") + defer span.End() + + if err := v.checkUnverifiedDomains(ctx); err != nil { + v.logger.ErrorCtx(ctx, "cannot check unverified domains", log.Error(err)) + } +} + +func (v *SAMLDomainVerifier) checkUnverifiedDomains(ctx context.Context) error { + var configs coredata.SAMLConfigurations + + err := v.pg.WithConn( + ctx, + func(conn pg.Conn) error { + err := configs.LoadUnverified(ctx, conn) + if err != nil { + return fmt.Errorf("cannot load unverified SAML configurations: %w", err) + } + + return nil + }, + ) + if err != nil { + return err + } + + for _, config := range configs { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + if err := v.tryVerifyDomain(ctx, config.ID); err != nil { + v.logger.ErrorCtx(ctx, "cannot verify domain", + log.String("config_id", config.ID.String()), + log.Error(err), + ) + + continue + } + } + + return nil +} + +func (v *SAMLDomainVerifier) tryVerifyDomain(ctx context.Context, configID gid.GID) error { + return v.pg.WithTx( + ctx, + func(tx pg.Conn) error { + config := &coredata.SAMLConfiguration{} + if err := config.LoadByIDForUpdateSkipLocked(ctx, tx, configID); err != nil { + if err == coredata.ErrResourceNotFound { + return nil + } + + return fmt.Errorf("cannot load SAML configuration: %w", err) + } + + if config.DomainVerifiedAt != nil { + return nil + } + + if config.DomainVerificationToken == nil { + return fmt.Errorf("cannot verify domain %q: no verification token", config.EmailDomain) + } + + expectedValue := txtRecordValuePrefix + *config.DomainVerificationToken + + if err := v.checkDNSTXTRecord(config.EmailDomain, expectedValue); err != nil { + return err + } + + v.logger.InfoCtx(ctx, "domain verified", + log.String("config_id", config.ID.String()), + ) + + now := time.Now() + config.DomainVerificationToken = nil + config.DomainVerifiedAt = &now + config.UpdatedAt = now + + scope := coredata.NewScopeFromObjectID(config.OrganizationID) + if err := config.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update SAML configuration: %w", err) + } + + return nil + }, + ) +} + +func (v *SAMLDomainVerifier) checkDNSTXTRecord(emailDomain string, expectedValue string) error { + fqdn := emailDomain + if !strings.HasSuffix(fqdn, ".") { + fqdn = fqdn + "." + } + + msg := &dns.Msg{MsgHeader: dns.MsgHeader{ID: dns.ID(), RecursionDesired: true}} + msg.Question = []dns.RR{&dns.TXT{Hdr: dns.Header{Name: fqdn, Class: dns.ClassINET}}} + + client := dns.NewClient() + resp, _, err := client.Exchange(context.Background(), msg, "udp", v.resolverAddr) + if err != nil { + return fmt.Errorf("cannot query TXT record for %q: %w", emailDomain, err) + } + + if resp.Rcode != dns.RcodeSuccess { + return fmt.Errorf("cannot query TXT record for %q: %s", emailDomain, dns.RcodeToString[resp.Rcode]) + } + + if len(resp.Answer) == 0 { + return fmt.Errorf("cannot find TXT record for %q", emailDomain) + } + + for _, answer := range resp.Answer { + txt, ok := answer.(*dns.TXT) + if !ok { + continue + } + + value := strings.Join(txt.Txt, "") + + if value == expectedValue { + return nil + } + } + + return fmt.Errorf("cannot find matching TXT record for %q", emailDomain) +} diff --git a/pkg/iam/service.go b/pkg/iam/service.go index b864e89d5..e3856ce76 100644 --- a/pkg/iam/service.go +++ b/pkg/iam/service.go @@ -9,12 +9,14 @@ import ( "go.gearno.de/kit/log" "go.gearno.de/kit/pg" + "go.opentelemetry.io/otel/trace" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/crypto/cipher" "go.probo.inc/probo/pkg/crypto/passwdhash" "go.probo.inc/probo/pkg/filemanager" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/iam/saml" + "golang.org/x/sync/errgroup" ) type ( @@ -42,21 +44,26 @@ type ( APIKeyService *APIKeyService LegacyAccessManagementService *AccessManagementService Authorizer *Authorizer + + samlDomainVerifier *SAMLDomainVerifier } Config struct { - DisableSignup bool - InvitationTokenValidity time.Duration - PasswordResetTokenValidity time.Duration - SessionDuration time.Duration - Bucket string - TokenSecret string - BaseURL string - EncryptionKey cipher.EncryptionKey - Certificate *x509.Certificate - PrivateKey *rsa.PrivateKey - Logger *log.Logger - PolicySet *PolicySet + DisableSignup bool + InvitationTokenValidity time.Duration + PasswordResetTokenValidity time.Duration + SessionDuration time.Duration + Bucket string + TokenSecret string + BaseURL string + EncryptionKey cipher.EncryptionKey + Certificate *x509.Certificate + PrivateKey *rsa.PrivateKey + Logger *log.Logger + PolicySet *PolicySet + TracerProvider trace.TracerProvider + DomainVerificationInterval time.Duration + DomainVerificationResolverAddr string } ) @@ -122,31 +129,24 @@ func NewService( } svc.SAMLService = samlService + svc.samlDomainVerifier = NewSAMLDomainVerifier( + pgClient, + cfg.Logger, + cfg.TracerProvider, + cfg.DomainVerificationInterval, + cfg.DomainVerificationResolverAddr, + ) + return svc, nil } func (s *Service) Run(ctx context.Context) error { - runCtx, stopAll := context.WithCancel(ctx) - defer stopAll() + g, ctx := errgroup.WithContext(ctx) - errCh := make(chan error, 1) - go func() { - errCh <- s.SAMLService.Run(runCtx) - }() + g.Go(func() error { return s.SAMLService.Run(ctx) }) + g.Go(func() error { return s.samlDomainVerifier.Run(ctx) }) - select { - case <-ctx.Done(): - stopAll() - <-errCh - return ctx.Err() - case err := <-errCh: - if err != nil { - s.logger.ErrorCtx(ctx, "iam service failed", log.Error(err)) - return err - } - - return nil - } + return g.Wait() } func (s *Service) GetMembership(ctx context.Context, membershipID gid.GID) (*coredata.Membership, error) { diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index 9bdf6af55..9768b930d 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -126,8 +126,10 @@ func New() *Implm { InvitationConfirmationTokenValidity: 3600, PasswordResetTokenValidity: 3600, SAML: samlConfig{ - SessionDuration: 604800, - CleanupIntervalSeconds: 86400, + SessionDuration: 604800, + CleanupIntervalSeconds: 86400, + DomainVerificationIntervalSeconds: 60, + DomainVerificationResolverAddr: "8.8.8.8:53", }, }, TrustAuth: trustAuthConfig{ @@ -313,17 +315,20 @@ func (impl *Implm) Run( fileManagerService, hp, iam.Config{ - DisableSignup: impl.cfg.Auth.DisableSignup, - InvitationTokenValidity: time.Duration(impl.cfg.Auth.InvitationConfirmationTokenValidity) * time.Second, - PasswordResetTokenValidity: time.Duration(impl.cfg.Auth.PasswordResetTokenValidity) * time.Second, - SessionDuration: time.Duration(impl.cfg.Auth.Cookie.Duration) * time.Hour, - Bucket: impl.cfg.AWS.Bucket, - TokenSecret: impl.cfg.Auth.Cookie.Secret, - BaseURL: impl.cfg.BaseURL.String(), - EncryptionKey: impl.cfg.EncryptionKey, - Certificate: samlCert, - PrivateKey: samlKey, - Logger: l.Named("iam"), + DisableSignup: impl.cfg.Auth.DisableSignup, + InvitationTokenValidity: time.Duration(impl.cfg.Auth.InvitationConfirmationTokenValidity) * time.Second, + PasswordResetTokenValidity: time.Duration(impl.cfg.Auth.PasswordResetTokenValidity) * time.Second, + SessionDuration: time.Duration(impl.cfg.Auth.Cookie.Duration) * time.Hour, + Bucket: impl.cfg.AWS.Bucket, + TokenSecret: impl.cfg.Auth.Cookie.Secret, + BaseURL: impl.cfg.BaseURL.String(), + EncryptionKey: impl.cfg.EncryptionKey, + Certificate: samlCert, + PrivateKey: samlKey, + Logger: l.Named("iam"), + TracerProvider: tp, + DomainVerificationInterval: impl.cfg.Auth.SAML.DomainVerificationInterval(), + DomainVerificationResolverAddr: impl.cfg.Auth.SAML.DomainVerificationResolverAddr, }, ) if err != nil { @@ -494,11 +499,9 @@ func (impl *Implm) Run( iamServiceCtx, stopIAMService := context.WithCancel(context.Background()) wg.Go( func() { - if err := iamService.Run(iamServiceCtx); err != nil { cancel(fmt.Errorf("iam service crashed: %w", err)) } - }, ) diff --git a/pkg/probod/saml_config.go b/pkg/probod/saml_config.go index 323504bb3..ddd8f3d77 100644 --- a/pkg/probod/saml_config.go +++ b/pkg/probod/saml_config.go @@ -19,10 +19,12 @@ import ( ) type samlConfig struct { - SessionDuration int `json:"session-duration"` - CleanupIntervalSeconds int `json:"cleanup-interval-seconds"` - Certificate string `json:"certificate"` - PrivateKey string `json:"private-key"` + SessionDuration int `json:"session-duration"` + CleanupIntervalSeconds int `json:"cleanup-interval-seconds"` + Certificate string `json:"certificate"` + PrivateKey string `json:"private-key"` + DomainVerificationIntervalSeconds int `json:"domain-verification-interval-seconds"` + DomainVerificationResolverAddr string `json:"domain-verification-resolver-addr"` } func (c samlConfig) SessionDurationTime() time.Duration { @@ -36,5 +38,10 @@ func (c samlConfig) CleanupInterval() time.Duration { if c.CleanupIntervalSeconds == 0 { return 0 } + return time.Duration(c.CleanupIntervalSeconds) * time.Second } + +func (c samlConfig) DomainVerificationInterval() time.Duration { + return time.Duration(c.DomainVerificationIntervalSeconds) * time.Second +} diff --git a/pkg/server/api/connect/v1/schema.graphql b/pkg/server/api/connect/v1/schema.graphql index 5e862da4e..f0a473f9e 100644 --- a/pkg/server/api/connect/v1/schema.graphql +++ b/pkg/server/api/connect/v1/schema.graphql @@ -318,9 +318,7 @@ type SessionPolicy { type SAMLConfiguration implements Node { id: ID! emailDomain: String! - enabled: Boolean! enforcementPolicy: SAMLEnforcementPolicy! - domainVerified: Boolean! domainVerifiedAt: Datetime domainVerificationToken: String idpEntityId: String! diff --git a/pkg/server/api/connect/v1/schema/schema.go b/pkg/server/api/connect/v1/schema/schema.go index 5a23e0550..175956125 100644 --- a/pkg/server/api/connect/v1/schema/schema.go +++ b/pkg/server/api/connect/v1/schema/schema.go @@ -355,10 +355,8 @@ type ComplexityRoot struct { CreatedAt func(childComplexity int) int DefaultPermissions func(childComplexity int) int DomainVerificationToken func(childComplexity int) int - DomainVerified func(childComplexity int) int DomainVerifiedAt func(childComplexity int) int EmailDomain func(childComplexity int) int - Enabled func(childComplexity int) int EnforcementPolicy func(childComplexity int) int ID func(childComplexity int) int IdpCertificate func(childComplexity int) int @@ -1733,12 +1731,6 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.complexity.SAMLConfiguration.DomainVerificationToken(childComplexity), true - case "SAMLConfiguration.domainVerified": - if e.complexity.SAMLConfiguration.DomainVerified == nil { - break - } - - return e.complexity.SAMLConfiguration.DomainVerified(childComplexity), true case "SAMLConfiguration.domainVerifiedAt": if e.complexity.SAMLConfiguration.DomainVerifiedAt == nil { break @@ -1751,12 +1743,6 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.complexity.SAMLConfiguration.EmailDomain(childComplexity), true - case "SAMLConfiguration.enabled": - if e.complexity.SAMLConfiguration.Enabled == nil { - break - } - - return e.complexity.SAMLConfiguration.Enabled(childComplexity), true case "SAMLConfiguration.enforcementPolicy": if e.complexity.SAMLConfiguration.EnforcementPolicy == nil { break @@ -2484,9 +2470,7 @@ type SessionPolicy { type SAMLConfiguration implements Node { id: ID! emailDomain: String! - enabled: Boolean! enforcementPolicy: SAMLEnforcementPolicy! - domainVerified: Boolean! domainVerifiedAt: Datetime domainVerificationToken: String idpEntityId: String! @@ -10090,35 +10074,6 @@ func (ec *executionContext) fieldContext_SAMLConfiguration_emailDomain(_ context return fc, nil } -func (ec *executionContext) _SAMLConfiguration_enabled(ctx context.Context, field graphql.CollectedField, obj *types.SAMLConfiguration) (ret graphql.Marshaler) { - return graphql.ResolveField( - ctx, - ec.OperationContext, - field, - ec.fieldContext_SAMLConfiguration_enabled, - func(ctx context.Context) (any, error) { - return obj.Enabled, nil - }, - nil, - ec.marshalNBoolean2bool, - true, - true, - ) -} - -func (ec *executionContext) fieldContext_SAMLConfiguration_enabled(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "SAMLConfiguration", - Field: field, - IsMethod: false, - IsResolver: false, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("field of type Boolean does not have child fields") - }, - } - return fc, nil -} - func (ec *executionContext) _SAMLConfiguration_enforcementPolicy(ctx context.Context, field graphql.CollectedField, obj *types.SAMLConfiguration) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -10148,35 +10103,6 @@ func (ec *executionContext) fieldContext_SAMLConfiguration_enforcementPolicy(_ c return fc, nil } -func (ec *executionContext) _SAMLConfiguration_domainVerified(ctx context.Context, field graphql.CollectedField, obj *types.SAMLConfiguration) (ret graphql.Marshaler) { - return graphql.ResolveField( - ctx, - ec.OperationContext, - field, - ec.fieldContext_SAMLConfiguration_domainVerified, - func(ctx context.Context) (any, error) { - return obj.DomainVerified, nil - }, - nil, - ec.marshalNBoolean2bool, - true, - true, - ) -} - -func (ec *executionContext) fieldContext_SAMLConfiguration_domainVerified(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "SAMLConfiguration", - Field: field, - IsMethod: false, - IsResolver: false, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("field of type Boolean does not have child fields") - }, - } - return fc, nil -} - func (ec *executionContext) _SAMLConfiguration_domainVerifiedAt(ctx context.Context, field graphql.CollectedField, obj *types.SAMLConfiguration) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -10672,12 +10598,8 @@ func (ec *executionContext) fieldContext_SAMLConfigurationEdge_node(_ context.Co return ec.fieldContext_SAMLConfiguration_id(ctx, field) case "emailDomain": return ec.fieldContext_SAMLConfiguration_emailDomain(ctx, field) - case "enabled": - return ec.fieldContext_SAMLConfiguration_enabled(ctx, field) case "enforcementPolicy": return ec.fieldContext_SAMLConfiguration_enforcementPolicy(ctx, field) - case "domainVerified": - return ec.fieldContext_SAMLConfiguration_domainVerified(ctx, field) case "domainVerifiedAt": return ec.fieldContext_SAMLConfiguration_domainVerifiedAt(ctx, field) case "domainVerificationToken": @@ -11739,12 +11661,8 @@ func (ec *executionContext) fieldContext_UpdateSAMLConfigurationPayload_samlConf return ec.fieldContext_SAMLConfiguration_id(ctx, field) case "emailDomain": return ec.fieldContext_SAMLConfiguration_emailDomain(ctx, field) - case "enabled": - return ec.fieldContext_SAMLConfiguration_enabled(ctx, field) case "enforcementPolicy": return ec.fieldContext_SAMLConfiguration_enforcementPolicy(ctx, field) - case "domainVerified": - return ec.fieldContext_SAMLConfiguration_domainVerified(ctx, field) case "domainVerifiedAt": return ec.fieldContext_SAMLConfiguration_domainVerifiedAt(ctx, field) case "domainVerificationToken": @@ -17312,21 +17230,11 @@ func (ec *executionContext) _SAMLConfiguration(ctx context.Context, sel ast.Sele if out.Values[i] == graphql.Null { out.Invalids++ } - case "enabled": - out.Values[i] = ec._SAMLConfiguration_enabled(ctx, field, obj) - if out.Values[i] == graphql.Null { - out.Invalids++ - } case "enforcementPolicy": out.Values[i] = ec._SAMLConfiguration_enforcementPolicy(ctx, field, obj) if out.Values[i] == graphql.Null { out.Invalids++ } - case "domainVerified": - out.Values[i] = ec._SAMLConfiguration_domainVerified(ctx, field, obj) - if out.Values[i] == graphql.Null { - out.Invalids++ - } case "domainVerifiedAt": out.Values[i] = ec._SAMLConfiguration_domainVerifiedAt(ctx, field, obj) case "domainVerificationToken": diff --git a/pkg/server/api/connect/v1/types/saml_configuration.go b/pkg/server/api/connect/v1/types/saml_configuration.go index b7e54c58d..43a5c55fd 100644 --- a/pkg/server/api/connect/v1/types/saml_configuration.go +++ b/pkg/server/api/connect/v1/types/saml_configuration.go @@ -64,12 +64,12 @@ func NewSAMLConfiguration(samlConfiguration *coredata.SAMLConfiguration) *SAMLCo ID: samlConfiguration.ID, EmailDomain: samlConfiguration.EmailDomain, EnforcementPolicy: samlConfiguration.EnforcementPolicy, - DomainVerified: samlConfiguration.DomainVerified, DomainVerifiedAt: samlConfiguration.DomainVerifiedAt, DomainVerificationToken: samlConfiguration.DomainVerificationToken, IdpEntityID: samlConfiguration.IdPEntityID, IdpSsoURL: samlConfiguration.IdPSsoURL, IdpCertificate: samlConfiguration.IdPCertificate, + AutoSignupEnabled: samlConfiguration.AutoSignupEnabled, CreatedAt: samlConfiguration.CreatedAt, UpdatedAt: samlConfiguration.UpdatedAt, AttributeMappings: &SAMLAttributeMappings{ diff --git a/pkg/server/api/connect/v1/types/types.go b/pkg/server/api/connect/v1/types/types.go index 98929c4ec..60947b472 100644 --- a/pkg/server/api/connect/v1/types/types.go +++ b/pkg/server/api/connect/v1/types/types.go @@ -400,9 +400,7 @@ func (SAMLAuthenticationRequired) IsAssumeOrganizationSessionResult() {} type SAMLConfiguration struct { ID gid.GID `json:"id"` EmailDomain string `json:"emailDomain"` - Enabled bool `json:"enabled"` EnforcementPolicy coredata.SAMLEnforcementPolicy `json:"enforcementPolicy"` - DomainVerified bool `json:"domainVerified"` DomainVerifiedAt *time.Time `json:"domainVerifiedAt,omitempty"` DomainVerificationToken *string `json:"domainVerificationToken,omitempty"` IdpEntityID string `json:"idpEntityId"`