Rewrite identity and access management

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2025-12-03 19:23:15 +01:00
parent 4ed3f5a067
commit 74fc3b8cd1
201 changed files with 32895 additions and 23649 deletions

View File

@@ -1,88 +0,0 @@
package auth
import (
"fmt"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
// AuthMethod represents a method of authentication
type AuthMethod int
const (
AuthMethodPassword AuthMethod = iota
AuthMethodSAML
AuthMethodAny
)
type OrgAuthRequirement struct {
OrganizationID gid.GID
EmailDomain string
SAMLConfig *coredata.SAMLConfiguration
}
type AccessResult struct {
OrganizationID gid.GID
Allowed bool
MissingAuth AuthMethod
SAMLConfig *coredata.SAMLConfiguration
}
func (r OrgAuthRequirement) Check(session coredata.SessionData) AccessResult {
if r.SAMLConfig == nil || !r.SAMLConfig.Enabled || !r.SAMLConfig.DomainVerified {
return AccessResult{
OrganizationID: r.OrganizationID,
Allowed: session.PasswordAuthenticated,
MissingAuth: AuthMethodPassword,
SAMLConfig: nil,
}
}
orgKey := r.OrganizationID.String()
_, hasSAML := session.SAMLAuthenticatedOrgs[orgKey]
if r.SAMLConfig.EnforcementPolicy == coredata.SAMLEnforcementPolicyRequired {
return AccessResult{
OrganizationID: r.OrganizationID,
Allowed: hasSAML,
MissingAuth: AuthMethodSAML,
SAMLConfig: r.SAMLConfig,
}
}
hasAnyAuth := session.PasswordAuthenticated || hasSAML
missingAuth := AuthMethodAny
if hasAnyAuth {
missingAuth = AuthMethodPassword
}
return AccessResult{
OrganizationID: r.OrganizationID,
Allowed: hasAnyAuth,
MissingAuth: missingAuth,
SAMLConfig: r.SAMLConfig,
}
}
func (r AccessResult) ToError(baseURL string) error {
if r.Allowed {
return nil
}
switch r.MissingAuth {
case AuthMethodPassword:
return ErrPasswordAuthRequired{
OrganizationID: r.OrganizationID,
RedirectURL: fmt.Sprintf("%s/auth/login?method=password", baseURL),
}
case AuthMethodSAML, AuthMethodAny:
return ErrSAMLAuthRequired{
ConfigID: r.SAMLConfig.ID,
OrganizationID: r.OrganizationID,
RedirectURL: fmt.Sprintf("%s/connect/saml/login/%s", baseURL, r.SAMLConfig.ID),
}
default:
return fmt.Errorf("access denied to organization %s", r.OrganizationID)
}
}

View File

@@ -1,109 +0,0 @@
// 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 auth
import (
"context"
"time"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
)
const (
DefaultCleanupInterval = 1 * time.Hour
)
type (
Cleaner struct {
pg *pg.Client
interval time.Duration
logger *log.Logger
}
)
func NewCleaner(
pg *pg.Client,
interval time.Duration,
logger *log.Logger,
) *Cleaner {
if interval == 0 {
interval = DefaultCleanupInterval
}
return &Cleaner{
pg: pg,
interval: interval,
logger: logger.Named("saml.cleaner"),
}
}
func (c *Cleaner) Run(ctx context.Context) error {
c.logger.InfoCtx(ctx, "SAML cleaner starting", log.Duration("interval", c.interval))
if err := c.cleanup(ctx); err != nil {
c.logger.ErrorCtx(ctx, "initial cleanup failed", log.Error(err))
}
ticker := time.NewTicker(c.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
c.logger.InfoCtx(ctx, "SAML cleaner shutting down")
return ctx.Err()
case <-ticker.C:
if err := c.cleanup(ctx); err != nil {
c.logger.ErrorCtx(ctx, "periodic cleanup failed", log.Error(err))
}
}
}
}
func (c *Cleaner) cleanup(ctx context.Context) error {
var assertionsDeleted, requestsDeleted int64
err := c.pg.WithConn(
ctx,
func(conn pg.Conn) error {
count, err := CleanupExpiredAssertions(ctx, conn)
if err != nil {
return err
}
assertionsDeleted = count
count, err = CleanupExpiredRequests(ctx, conn)
if err != nil {
return err
}
requestsDeleted = count
return nil
},
)
if err != nil {
return err
}
if assertionsDeleted > 0 || requestsDeleted > 0 {
c.logger.InfoCtx(ctx, "cleaned up expired SAML data",
log.Int64("assertions", assertionsDeleted),
log.Int64("requests", requestsDeleted))
}
return nil
}

View File

@@ -1,288 +0,0 @@
// 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 auth
import (
"context"
"fmt"
"strings"
"time"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.gearno.de/kit/pg"
)
type (
CreateSAMLConfigurationRequest struct {
OrganizationID gid.GID
EmailDomain string
EnforcementPolicy coredata.SAMLEnforcementPolicy
IdPEntityID string
IdPSsoURL string
IdPCertificate string
IdPMetadataURL *string
AttributeEmail string
AttributeFirstname string
AttributeLastname string
AttributeRole string
AutoSignupEnabled bool
}
UpdateSAMLConfigurationRequest struct {
ID gid.GID
Enabled *bool
EnforcementPolicy *coredata.SAMLEnforcementPolicy
IdPEntityID *string
IdPSsoURL *string
IdPCertificate *string
IdPMetadataURL *string
AttributeEmail *string
AttributeFirstname *string
AttributeLastname *string
AttributeRole *string
AutoSignupEnabled *bool
}
)
func (s TenantAuthService) CreateSAMLConfiguration(
ctx context.Context,
req CreateSAMLConfigurationRequest,
) (*coredata.SAMLConfiguration, error) {
// Validate only the IdP configuration (user-provided data)
if err := ValidateIdPConfiguration(req.IdPEntityID, req.IdPSsoURL, req.IdPCertificate); err != nil {
return nil, fmt.Errorf("SAML configuration validation failed: %w", err)
}
var config *coredata.SAMLConfiguration
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
now := time.Now()
tenantID := s.scope.GetTenantID()
var org coredata.Organization
if err := org.LoadByID(ctx, tx, s.scope, req.OrganizationID); err != nil {
return fmt.Errorf("organization not found: %w", err)
}
config = &coredata.SAMLConfiguration{
ID: gid.New(tenantID, coredata.SAMLConfigurationEntityType),
OrganizationID: org.ID,
EmailDomain: req.EmailDomain,
EnforcementPolicy: req.EnforcementPolicy,
Enabled: false,
IdPEntityID: req.IdPEntityID,
IdPSsoURL: req.IdPSsoURL,
IdPCertificate: req.IdPCertificate,
IdPMetadataURL: req.IdPMetadataURL,
AttributeEmail: req.AttributeEmail,
AttributeFirstname: req.AttributeFirstname,
AttributeLastname: req.AttributeLastname,
AttributeRole: req.AttributeRole,
AutoSignupEnabled: req.AutoSignupEnabled,
CreatedAt: now,
UpdatedAt: now,
}
if err := config.Insert(ctx, tx, s.scope); err != nil {
return fmt.Errorf("cannot insert saml configuration: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return config, nil
}
func (s TenantAuthService) UpdateSAMLConfiguration(
ctx context.Context,
req UpdateSAMLConfigurationRequest,
) (*coredata.SAMLConfiguration, error) {
var config *coredata.SAMLConfiguration
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
var cfg coredata.SAMLConfiguration
if err := cfg.LoadByID(ctx, tx, s.scope, req.ID); err != nil {
return fmt.Errorf("cannot load saml configuration: %w", err)
}
if req.Enabled != nil {
cfg.Enabled = *req.Enabled
}
if req.EnforcementPolicy != nil {
cfg.EnforcementPolicy = *req.EnforcementPolicy
}
if req.IdPEntityID != nil {
cfg.IdPEntityID = *req.IdPEntityID
}
if req.IdPSsoURL != nil {
cfg.IdPSsoURL = *req.IdPSsoURL
}
if req.IdPCertificate != nil {
cfg.IdPCertificate = *req.IdPCertificate
}
if req.IdPMetadataURL != nil {
cfg.IdPMetadataURL = req.IdPMetadataURL
}
if req.AttributeEmail != nil {
cfg.AttributeEmail = *req.AttributeEmail
}
if req.AttributeFirstname != nil {
cfg.AttributeFirstname = *req.AttributeFirstname
}
if req.AttributeLastname != nil {
cfg.AttributeLastname = *req.AttributeLastname
}
if req.AttributeRole != nil {
cfg.AttributeRole = *req.AttributeRole
}
if req.AutoSignupEnabled != nil {
cfg.AutoSignupEnabled = *req.AutoSignupEnabled
}
cfg.UpdatedAt = time.Now()
if err := cfg.Update(ctx, tx, s.scope); err != nil {
return fmt.Errorf("cannot update saml configuration: %w", err)
}
config = &cfg
return nil
},
)
if err != nil {
return nil, err
}
return config, nil
}
func (s TenantAuthService) DeleteSAMLConfiguration(
ctx context.Context,
configID gid.GID,
) error {
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
var config coredata.SAMLConfiguration
if err := config.LoadByID(ctx, tx, s.scope, configID); err != nil {
return fmt.Errorf("cannot load saml configuration: %w", err)
}
if err := config.Delete(ctx, tx, s.scope); err != nil {
return fmt.Errorf("cannot delete saml configuration: %w", err)
}
return nil
},
)
}
func (s TenantAuthService) EnableSAMLConfiguration(
ctx context.Context,
configID gid.GID,
) (*coredata.SAMLConfiguration, error) {
enabled := true
return s.UpdateSAMLConfiguration(ctx, UpdateSAMLConfigurationRequest{
ID: configID,
Enabled: &enabled,
})
}
func (s TenantAuthService) DisableSAMLConfiguration(
ctx context.Context,
configID gid.GID,
) (*coredata.SAMLConfiguration, error) {
disabled := false
return s.UpdateSAMLConfiguration(ctx, UpdateSAMLConfigurationRequest{
ID: configID,
Enabled: &disabled,
})
}
func (s TenantAuthService) GetSAMLConfigurationByID(
ctx context.Context,
configID gid.GID,
) (*coredata.SAMLConfiguration, error) {
var config coredata.SAMLConfiguration
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
return config.LoadByID(ctx, conn, s.scope, configID)
},
)
if err != nil {
return nil, fmt.Errorf("cannot load saml configuration: %w", err)
}
return &config, nil
}
func (s TenantAuthService) GetSAMLConfigurationsByOrganizationID(
ctx context.Context,
organizationID gid.GID,
) ([]*coredata.SAMLConfiguration, error) {
var configs []*coredata.SAMLConfiguration
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
var err error
configs, err = coredata.LoadSAMLConfigurationsByOrganizationID(ctx, conn, s.scope, organizationID)
return err
},
)
if err != nil {
return nil, fmt.Errorf("cannot load saml configurations: %w", err)
}
return configs, nil
}
func (s Service) CheckSSOAvailabilityByEmail(
ctx context.Context,
email string,
) ([]*coredata.SAMLConfiguration, error) {
// Extract domain from email
parts := strings.Split(email, "@")
if len(parts) != 2 {
return nil, fmt.Errorf("invalid email format")
}
domain := parts[1]
var configs []*coredata.SAMLConfiguration
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
var err error
configs, err = coredata.LoadAllEnabledSAMLConfigurationsByEmailDomain(ctx, conn, domain)
return err
},
)
if err != nil {
return nil, fmt.Errorf("cannot load saml configurations: %w", err)
}
return configs, nil
}

View File

@@ -1,204 +0,0 @@
// 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 auth
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/pem"
"encoding/xml"
"fmt"
"math/big"
"time"
"github.com/crewjam/saml"
)
func GenerateServiceProviderMetadata(
entityID string,
acsURL string,
spCert *x509.Certificate,
) ([]byte, error) {
certData := base64.StdEncoding.EncodeToString(spCert.Raw)
trueVal := true
metadata := &saml.EntityDescriptor{
EntityID: entityID,
SPSSODescriptors: []saml.SPSSODescriptor{
{
SSODescriptor: saml.SSODescriptor{
RoleDescriptor: saml.RoleDescriptor{
ProtocolSupportEnumeration: "urn:oasis:names:tc:SAML:2.0:protocol",
KeyDescriptors: []saml.KeyDescriptor{
{
Use: "signing",
KeyInfo: saml.KeyInfo{
X509Data: saml.X509Data{
X509Certificates: []saml.X509Certificate{
{Data: certData},
},
},
},
},
{
Use: "encryption",
KeyInfo: saml.KeyInfo{
X509Data: saml.X509Data{
X509Certificates: []saml.X509Certificate{
{Data: certData},
},
},
},
},
},
},
},
AuthnRequestsSigned: &trueVal,
WantAssertionsSigned: &trueVal,
AssertionConsumerServices: []saml.IndexedEndpoint{
{
Binding: saml.HTTPPostBinding,
Location: acsURL,
Index: 0,
},
},
},
},
}
xmlBytes, err := xml.MarshalIndent(metadata, "", " ")
if err != nil {
return nil, fmt.Errorf("cannot marshal SP metadata to XML: %w", err)
}
return xmlBytes, nil
}
func ParseIdPCertificate(certPEM string) (*x509.Certificate, error) {
block, _ := pem.Decode([]byte(certPEM))
if block == nil {
return nil, fmt.Errorf("cannot decode PEM block from IdP certificate")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("cannot parse X.509 certificate: %w", err)
}
return cert, nil
}
type IdPMetadata struct {
EntityID string
SsoURL string
Certificate string
MetadataURL *string
}
func ParseIdPMetadata(metadataXML string) (*IdPMetadata, error) {
var entityDescriptor saml.EntityDescriptor
if err := xml.Unmarshal([]byte(metadataXML), &entityDescriptor); err != nil {
return nil, fmt.Errorf("cannot parse IdP metadata XML: %w", err)
}
if len(entityDescriptor.IDPSSODescriptors) == 0 {
return nil, fmt.Errorf("no IDPSSODescriptor found in metadata")
}
idpDescriptor := entityDescriptor.IDPSSODescriptors[0]
var ssoURL string
for _, sso := range idpDescriptor.SingleSignOnServices {
if sso.Binding == saml.HTTPPostBinding || sso.Binding == saml.HTTPRedirectBinding {
ssoURL = sso.Location
break
}
}
if ssoURL == "" && len(idpDescriptor.SingleSignOnServices) > 0 {
ssoURL = idpDescriptor.SingleSignOnServices[0].Location
}
if ssoURL == "" {
return nil, fmt.Errorf("no SingleSignOnService found in metadata")
}
var certPEM string
for _, keyDescriptor := range idpDescriptor.KeyDescriptors {
if keyDescriptor.Use == "signing" || keyDescriptor.Use == "" {
if len(keyDescriptor.KeyInfo.X509Data.X509Certificates) > 0 {
certData := keyDescriptor.KeyInfo.X509Data.X509Certificates[0].Data
certDER, err := base64.StdEncoding.DecodeString(certData)
if err != nil {
return nil, fmt.Errorf("cannot decode certificate: %w", err)
}
certPEM = string(pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certDER,
}))
break
}
}
}
if certPEM == "" {
return nil, fmt.Errorf("no signing certificate found in metadata")
}
return &IdPMetadata{
EntityID: entityDescriptor.EntityID,
SsoURL: ssoURL,
Certificate: certPEM,
}, nil
}
func GenerateSelfSignedCertificate(entityID string) (*x509.Certificate, *rsa.PrivateKey, error) {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, nil, fmt.Errorf("cannot generate RSA private key: %w", err)
}
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
return nil, nil, fmt.Errorf("cannot generate serial number: %w", err)
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
CommonName: entityID,
Organization: []string{"Probo"},
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
if err != nil {
return nil, nil, fmt.Errorf("cannot create certificate: %w", err)
}
cert, err := x509.ParseCertificate(certDER)
if err != nil {
return nil, nil, fmt.Errorf("cannot parse created certificate: %w", err)
}
return cert, privateKey, nil
}

View File

@@ -1,556 +0,0 @@
// 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 auth
import (
"context"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/crewjam/saml"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/crypto/cipher"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/mail"
)
type (
SAMLService struct {
pg *pg.Client
encryptionKey cipher.EncryptionKey
baseURL string
sessionDuration time.Duration
cookieName string
cookieSecret string
certificate *x509.Certificate
privateKey *rsa.PrivateKey
logger *log.Logger
}
ErrSPCertificateNotConfigured struct{}
ErrSAMLConfigurationNotFound struct {
OrganizationID gid.GID
}
ErrSAMLDisabled struct {
OrganizationID gid.GID
}
ErrInvalidIdPCertificate struct {
Err error
}
ErrInvalidURL struct {
Field string
URL string
Err error
}
ErrCannotCreateServiceProvider struct {
Err error
}
ErrCannotCreateAuthRequest struct {
Err error
}
ErrCannotGenerateRedirectURL struct {
Err error
}
ErrCannotParseSAMLResponse struct {
Err error
}
ErrCannotValidateAssertion struct {
Err error
}
ErrCannotExtractUserAttributes struct {
Err error
}
ErrCannotMapRole struct {
Err error
}
ErrReplayAttackDetected struct {
AssertionID string
Err error
}
)
func (e ErrSPCertificateNotConfigured) Error() string {
return "SP certificate and private key are not configured"
}
func (e ErrSAMLConfigurationNotFound) Error() string {
return fmt.Sprintf("SAML configuration not found for organization %s", e.OrganizationID)
}
func (e ErrSAMLDisabled) Error() string {
return fmt.Sprintf("SAML is disabled for organization %s", e.OrganizationID)
}
func (e ErrInvalidIdPCertificate) Error() string {
return fmt.Sprintf("cannot parse IdP certificate: %v", e.Err)
}
func (e ErrInvalidURL) Error() string {
return fmt.Sprintf("cannot parse %s URL %q: %v", e.Field, e.URL, e.Err)
}
func (e ErrCannotCreateServiceProvider) Error() string {
return fmt.Sprintf("cannot create service provider: %v", e.Err)
}
func (e ErrCannotCreateAuthRequest) Error() string {
return fmt.Sprintf("cannot create AuthnRequest: %v", e.Err)
}
func (e ErrCannotGenerateRedirectURL) Error() string {
return fmt.Sprintf("cannot generate redirect URL: %v", e.Err)
}
func (e ErrCannotParseSAMLResponse) Error() string {
return fmt.Sprintf("cannot parse SAML response: %v", e.Err)
}
func (e ErrCannotValidateAssertion) Error() string {
return fmt.Sprintf("cannot validate assertion: %v", e.Err)
}
func (e ErrCannotExtractUserAttributes) Error() string {
return fmt.Sprintf("cannot extract user attributes: %v", e.Err)
}
func (e ErrCannotMapRole) Error() string {
return fmt.Sprintf("cannot map role: %v", e.Err)
}
func (e ErrReplayAttackDetected) Error() string {
return fmt.Sprintf("replay attack detected for assertion %s: %v", e.AssertionID, e.Err)
}
func NewSAMLService(
pg *pg.Client,
encryptionKey cipher.EncryptionKey,
baseURL string,
sessionDuration time.Duration,
cookieName string,
cookieSecret string,
certificatePEM string,
privateKeyPEM string,
logger *log.Logger,
) (*SAMLService, error) {
var certificate *x509.Certificate
var privateKey *rsa.PrivateKey
if certificatePEM != "" {
block, _ := pem.Decode([]byte(certificatePEM))
if block == nil || block.Type != "CERTIFICATE" {
return nil, fmt.Errorf("invalid certificate PEM format")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("cannot parse certificate: %w", err)
}
certificate = cert
}
if privateKeyPEM != "" {
block, _ := pem.Decode([]byte(privateKeyPEM))
if block == nil {
return nil, fmt.Errorf("invalid private key PEM format")
}
var key *rsa.PrivateKey
var err error
switch block.Type {
case "RSA PRIVATE KEY":
key, err = x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("cannot parse PKCS1 private key: %w", err)
}
case "PRIVATE KEY":
parsedKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("cannot parse PKCS8 private key: %w", err)
}
var ok bool
key, ok = parsedKey.(*rsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("private key is not RSA")
}
default:
return nil, fmt.Errorf("unsupported private key type: %s", block.Type)
}
privateKey = key
}
return &SAMLService{
pg: pg,
encryptionKey: encryptionKey,
baseURL: baseURL,
sessionDuration: sessionDuration,
cookieName: cookieName,
cookieSecret: cookieSecret,
certificate: certificate,
privateKey: privateKey,
logger: logger,
}, nil
}
func (s *SAMLService) GetEntityID() string {
return fmt.Sprintf("%s/connect/saml/metadata", s.baseURL)
}
func (s *SAMLService) GetAcsURL() string {
return fmt.Sprintf("%s/connect/saml/consume", s.baseURL)
}
func (s *SAMLService) GetServiceProvider(
ctx context.Context,
config *coredata.SAMLConfiguration,
) (*saml.ServiceProvider, error) {
if s.certificate == nil || s.privateKey == nil {
return nil, ErrSPCertificateNotConfigured{}
}
idpCert, err := ParseIdPCertificate(config.IdPCertificate)
if err != nil {
return nil, ErrInvalidIdPCertificate{Err: err}
}
acsURL, err := url.Parse(s.GetAcsURL())
if err != nil {
return nil, ErrInvalidURL{Field: "ACS", URL: s.GetAcsURL(), Err: err}
}
metadataURL, err := url.Parse(s.GetEntityID())
if err != nil {
return nil, ErrInvalidURL{Field: "Metadata", URL: s.GetEntityID(), Err: err}
}
idpSSOURL, err := url.Parse(config.IdPSsoURL)
if err != nil {
return nil, ErrInvalidURL{Field: "IdP SSO", URL: config.IdPSsoURL, Err: err}
}
sp := &saml.ServiceProvider{
EntityID: s.GetEntityID(),
Key: s.privateKey,
Certificate: s.certificate,
MetadataURL: *metadataURL,
AcsURL: *acsURL,
SloURL: *acsURL,
AllowIDPInitiated: true,
IDPMetadata: &saml.EntityDescriptor{
EntityID: config.IdPEntityID,
IDPSSODescriptors: []saml.IDPSSODescriptor{
{
SSODescriptor: saml.SSODescriptor{
RoleDescriptor: saml.RoleDescriptor{
ProtocolSupportEnumeration: "urn:oasis:names:tc:SAML:2.0:protocol",
KeyDescriptors: []saml.KeyDescriptor{
{
Use: "signing",
KeyInfo: saml.KeyInfo{
X509Data: saml.X509Data{
X509Certificates: []saml.X509Certificate{
{Data: base64.StdEncoding.EncodeToString(idpCert.Raw)},
},
},
},
},
},
},
},
SingleSignOnServices: []saml.Endpoint{
{
Binding: saml.HTTPRedirectBinding,
Location: idpSSOURL.String(),
},
},
},
},
},
}
return sp, nil
}
func (s *SAMLService) InitiateSAMLLogin(
ctx context.Context,
organizationID gid.GID,
tenantID gid.TenantID,
emailDomain string,
) (string, error) {
var config coredata.SAMLConfiguration
scope := coredata.NewScope(tenantID)
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
return config.LoadByOrganizationIDAndEmailDomain(ctx, conn, scope, organizationID, emailDomain)
},
)
if err != nil {
return "", ErrSAMLConfigurationNotFound{OrganizationID: organizationID}
}
if !config.Enabled {
return "", ErrSAMLDisabled{OrganizationID: organizationID}
}
sp, err := s.GetServiceProvider(ctx, &config)
if err != nil {
return "", ErrCannotCreateServiceProvider{Err: err}
}
authReq, err := sp.MakeAuthenticationRequest(
config.IdPSsoURL,
saml.HTTPRedirectBinding,
saml.HTTPPostBinding,
)
if err != nil {
return "", ErrCannotCreateAuthRequest{Err: err}
}
now := time.Now()
requestExpiry := now.Add(10 * time.Minute)
err = s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
samlRequest := coredata.SAMLRequest{
ID: authReq.ID,
OrganizationID: organizationID,
CreatedAt: now,
ExpiresAt: requestExpiry,
}
if err := samlRequest.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert SAML request: %w", err)
}
return nil
},
)
if err != nil {
return "", err
}
redirectURL, err := authReq.Redirect(config.ID.String(), sp)
if err != nil {
return "", ErrCannotGenerateRedirectURL{Err: err}
}
return redirectURL.String(), nil
}
type SAMLUserInfo struct {
Email mail.Addr
FullName string
Role *coredata.MembershipRole
SAMLSubject string
OrganizationID gid.GID
SAMLConfigID gid.GID
}
func (s *SAMLService) loadConfigFromRelayState(
ctx context.Context,
relayStateValue string,
) (*coredata.SAMLConfiguration, *coredata.Organization, error) {
if relayStateValue == "" {
return nil, nil, fmt.Errorf("RelayState is required and must contain SAML config ID")
}
samlConfigID, err := gid.ParseGID(relayStateValue)
if err != nil {
return nil, nil, fmt.Errorf("invalid SAML config ID in RelayState: %w", err)
}
var config coredata.SAMLConfiguration
var org coredata.Organization
err = s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := config.LoadByID(ctx, conn, coredata.NewNoScope(), samlConfigID); err != nil {
return fmt.Errorf("cannot load SAML configuration: %w", err)
}
if err := org.LoadByID(ctx, conn, coredata.NewNoScope(), config.OrganizationID); err != nil {
return fmt.Errorf("organization not found: %w", err)
}
return nil
},
)
if err != nil {
return nil, nil, err
}
return &config, &org, nil
}
func (s *SAMLService) HandleSAMLAssertion(
ctx context.Context,
req *http.Request,
) (*SAMLUserInfo, error) {
samlResponseEncoded := req.FormValue("SAMLResponse")
if samlResponseEncoded == "" {
return nil, fmt.Errorf("missing SAMLResponse in request")
}
relayStateValue := req.FormValue("RelayState")
config, org, err := s.loadConfigFromRelayState(ctx, relayStateValue)
if err != nil {
return nil, err
}
if !config.Enabled {
return nil, ErrSAMLDisabled{OrganizationID: config.OrganizationID}
}
sp, err := s.GetServiceProvider(ctx, config)
if err != nil {
return nil, ErrCannotCreateServiceProvider{Err: err}
}
if req.URL.Scheme == "" {
req.URL.Scheme = "https"
}
if req.URL.Host == "" {
req.URL.Host = req.Host
}
now := time.Now()
var possibleRequestIDs []string
err = s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
requestIDs, err := coredata.LoadValidRequestIDsForOrganization(ctx, conn, config.OrganizationID, now)
if err != nil {
return err
}
possibleRequestIDs = requestIDs
return nil
},
)
if err != nil {
return nil, fmt.Errorf("cannot load valid request IDs: %w", err)
}
assertion, err := sp.ParseResponse(req, possibleRequestIDs)
if err != nil {
return nil, fmt.Errorf("cannot parse SAML response: %w", err)
}
if err := ValidateAssertion(assertion, s.GetEntityID(), now); err != nil {
return nil, ErrCannotValidateAssertion{Err: err}
}
if assertion.ID != "" {
var expiresAt time.Time
if assertion.Conditions != nil && !assertion.Conditions.NotOnOrAfter.IsZero() {
expiresAt = assertion.Conditions.NotOnOrAfter
} else {
expiresAt = now.Add(24 * time.Hour)
}
scope := coredata.NewScope(org.TenantID)
err = s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
if err := PreventReplayAttack(ctx, tx, scope, assertion.ID, config.OrganizationID, expiresAt); err != nil {
return fmt.Errorf("cannot prevent replay attack: %w", err)
}
return nil
},
)
if err != nil {
var replayAttackErr *coredata.ErrAssertionAlreadyUsed
if errors.As(err, &replayAttackErr) {
return nil, ErrReplayAttackDetected{AssertionID: assertion.ID, Err: replayAttackErr}
}
return nil, fmt.Errorf("cannot prevent replay attack: %w", err)
}
}
email, fullname, samlRole, err := ExtractUserAttributes(
assertion,
config.AttributeEmail,
config.AttributeFirstname,
config.AttributeLastname,
config.AttributeRole,
)
if err != nil {
return nil, ErrCannotExtractUserAttributes{Err: err}
}
if !strings.EqualFold(email.Domain(), config.EmailDomain) {
return nil, fmt.Errorf("email domain mismatch: assertion contains email with domain %s but SAML config is for domain %s", email.Domain(), config.EmailDomain)
}
systemRole := MapSAMLRoleToSystemRole(samlRole)
samlSubject := ""
if assertion.Subject != nil && assertion.Subject.NameID != nil {
samlSubject = assertion.Subject.NameID.Value
}
return &SAMLUserInfo{
Email: email,
FullName: fullname,
Role: systemRole,
SAMLSubject: samlSubject,
OrganizationID: config.OrganizationID,
SAMLConfigID: config.ID,
}, nil
}
func (s *SAMLService) GetMetadataURL(organizationID gid.GID) string {
return fmt.Sprintf("%s/connect/saml/metadata/%s", s.baseURL, organizationID)
}
func (s *SAMLService) GenerateMetadata() ([]byte, error) {
if s.certificate == nil {
return nil, ErrSPCertificateNotConfigured{}
}
return GenerateServiceProviderMetadata(
s.GetEntityID(),
s.GetAcsURL(),
s.certificate,
)
}

View File

@@ -1,103 +0,0 @@
// 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 auth
import (
"context"
"errors"
"fmt"
"time"
"github.com/crewjam/saml"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"github.com/jackc/pgx/v5/pgconn"
"go.gearno.de/kit/pg"
)
func PreventReplayAttack(
ctx context.Context,
conn pg.Conn,
scope coredata.Scoper,
assertionID string,
organizationID gid.GID,
expiresAt time.Time,
) error {
now := time.Now()
assertion := coredata.SAMLAssertion{
ID: assertionID,
OrganizationID: organizationID,
UsedAt: now,
ExpiresAt: expiresAt,
}
if err := assertion.Insert(ctx, conn, scope); err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" && pgErr.ConstraintName == "auth_saml_assertions_pkey" {
return coredata.ErrAssertionAlreadyUsed{AssertionID: assertionID}
}
return fmt.Errorf("cannot store assertion ID: %w", err)
}
return nil
}
func ValidateAssertion(
assertion *saml.Assertion,
expectedAudience string,
now time.Time,
) error {
const clockSkewTolerance = 5 * time.Minute
if assertion.Conditions != nil && !assertion.Conditions.NotBefore.IsZero() {
if now.Add(clockSkewTolerance).Before(assertion.Conditions.NotBefore) {
return fmt.Errorf("assertion not yet valid (NotBefore: %v, now: %v, tolerance: %v)",
assertion.Conditions.NotBefore, now, clockSkewTolerance)
}
}
if assertion.Conditions != nil && !assertion.Conditions.NotOnOrAfter.IsZero() {
if now.Add(-clockSkewTolerance).After(assertion.Conditions.NotOnOrAfter) ||
now.Add(-clockSkewTolerance).Equal(assertion.Conditions.NotOnOrAfter) {
return fmt.Errorf("assertion expired (NotOnOrAfter: %v, now: %v, tolerance: %v)",
assertion.Conditions.NotOnOrAfter, now, clockSkewTolerance)
}
}
if assertion.Conditions != nil && len(assertion.Conditions.AudienceRestrictions) > 0 {
audienceValid := false
for _, restriction := range assertion.Conditions.AudienceRestrictions {
if restriction.Audience.Value == expectedAudience {
audienceValid = true
break
}
}
if !audienceValid {
return fmt.Errorf("assertion audience restriction does not match expected audience %q", expectedAudience)
}
}
return nil
}
func CleanupExpiredAssertions(ctx context.Context, conn pg.Conn) (int64, error) {
return coredata.DeleteExpiredSAMLAssertions(ctx, conn, time.Now())
}
func CleanupExpiredRequests(ctx context.Context, conn pg.Conn) (int64, error) {
return coredata.DeleteExpiredSAMLRequests(ctx, conn, time.Now())
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,933 +0,0 @@
// 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 authz
import (
"context"
"errors"
"fmt"
"net/url"
"slices"
"time"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/packages/emails"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/mail"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/statelesstoken"
)
type TenantAccessError struct {
Message string
}
func (e *TenantAccessError) Error() string {
return "not authorized"
}
type PermissionDeniedError struct {
Message string
}
func (e *PermissionDeniedError) Error() string {
return e.Message
}
type (
Service struct {
pg *pg.Client
baseURL string
tokenSecret string
invitationTokenValidity time.Duration
}
TenantAuthzService struct {
pg *pg.Client
baseURL string
tokenSecret string
invitationTokenValidity time.Duration
scope coredata.Scoper
}
)
const (
TokenTypeOrganizationInvitation = "organization_invitation"
)
func NewService(
ctx context.Context,
pgClient *pg.Client,
baseURL string,
tokenSecret string,
invitationTokenValidity time.Duration,
) (*Service, error) {
return &Service{
pg: pgClient,
baseURL: baseURL,
tokenSecret: tokenSecret,
invitationTokenValidity: invitationTokenValidity,
}, nil
}
func (s *Service) WithTenant(tenantID gid.TenantID) *TenantAuthzService {
return &TenantAuthzService{
pg: s.pg,
baseURL: s.baseURL,
tokenSecret: s.tokenSecret,
invitationTokenValidity: s.invitationTokenValidity,
scope: coredata.NewScope(tenantID),
}
}
func (s *Service) GetAllUserOrganizations(
ctx context.Context,
userID gid.GID,
) (coredata.Organizations, error) {
organizations := coredata.Organizations{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := organizations.LoadAllByUserID(ctx, conn, userID); err != nil {
return fmt.Errorf("cannot load user organizations: %w", err)
}
return nil
},
)
return organizations, err
}
func (s *Service) GetUserOrganizationsWithRole(
ctx context.Context,
userID gid.GID,
role coredata.MembershipRole,
) (coredata.Organizations, error) {
organizations := coredata.Organizations{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := organizations.LoadAllByUserIDWithRole(ctx, conn, userID, role); err != nil {
return fmt.Errorf("cannot load user organizations with role: %w", err)
}
return nil
},
)
return organizations, err
}
func (s *Service) GetAllOrganizationsForUserAPIKeyId(
ctx context.Context,
userAPIKeyID gid.GID,
) (coredata.Organizations, error) {
organizations := coredata.Organizations{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := organizations.LoadAllByUserAPIKeyID(ctx, conn, userAPIKeyID); err != nil {
return fmt.Errorf("cannot load user api key organizations: %w", err)
}
return nil
},
)
return organizations, err
}
func (s *Service) GetUserOrganizations(
ctx context.Context,
userID gid.GID,
cursor *page.Cursor[coredata.OrganizationOrderField],
) (coredata.Organizations, error) {
organizations := coredata.Organizations{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := organizations.LoadByUserID(ctx, conn, coredata.NewNoScope(), userID, cursor); err != nil {
return fmt.Errorf("cannot load user organizations: %w", err)
}
return nil
},
)
return organizations, err
}
func (s *Service) AcceptInvitationByID(
ctx context.Context,
invitationID gid.GID,
userID gid.GID,
) (*coredata.Invitation, error) {
var acceptedInvitation *coredata.Invitation
scope := coredata.NewScope(invitationID.TenantID())
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
invitation := &coredata.Invitation{}
if err := invitation.LoadByID(ctx, tx, scope, invitationID); err != nil {
var errInvitationNotFound *coredata.ErrInvitationNotFound
if errors.As(err, &errInvitationNotFound) {
return fmt.Errorf("invitation was deleted or no longer exists")
}
return fmt.Errorf("cannot load invitation: %w", err)
}
if invitation.AcceptedAt != nil {
return fmt.Errorf("invitation already accepted")
}
if time.Now().After(invitation.ExpiresAt) {
return fmt.Errorf("invitation expired")
}
user := &coredata.User{}
if err := user.LoadByID(ctx, tx, userID); err != nil {
return fmt.Errorf("cannot load user: %w", err)
}
if invitation.Email != user.EmailAddress {
return fmt.Errorf("invitation email does not match user email")
}
now := time.Now()
membershipID := gid.New(scope.GetTenantID(), coredata.MembershipEntityType)
membership := &coredata.Membership{
ID: membershipID,
UserID: userID,
OrganizationID: invitation.OrganizationID,
Role: invitation.Role,
CreatedAt: now,
UpdatedAt: now,
}
if err := membership.Create(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot add user to organization: %w", err)
}
invitation.AcceptedAt = &now
if err := invitation.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot mark invitation as accepted: %w", err)
}
acceptedInvitation = invitation
return nil
},
)
if err != nil {
return nil, err
}
return acceptedInvitation, nil
}
type UserInvitation struct {
ID gid.GID
Email mail.Addr
FullName string
Role coredata.MembershipRole
ExpiresAt time.Time
AcceptedAt *time.Time
CreatedAt time.Time
OrganizationID gid.GID
Organization OrganizationSummary
}
type OrganizationSummary struct {
ID gid.GID
Name string
}
func (s *Service) GetUserPendingInvitations(
ctx context.Context,
email mail.Addr,
) ([]*UserInvitation, error) {
userInvitations := []*UserInvitation{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
cursor := page.NewCursor(
1000,
nil,
page.Head,
page.OrderBy[coredata.InvitationOrderField]{
Field: coredata.InvitationOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
},
)
filter := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending})
invitations := coredata.Invitations{}
if err := invitations.LoadByEmail(ctx, conn, coredata.NewNoScope(), email, cursor, filter); err != nil {
return fmt.Errorf("cannot load invitations: %w", err)
}
organizationIDs := []gid.GID{}
for _, invitation := range invitations {
organizationIDs = append(organizationIDs, invitation.OrganizationID)
}
organizations := coredata.Organizations{}
if err := organizations.BatchLoadByID(ctx, conn, coredata.NewNoScope(), organizationIDs); err != nil {
return fmt.Errorf("cannot load organizations: %w", err)
}
for _, invitation := range invitations {
userInvitation := &UserInvitation{
ID: invitation.ID,
Email: invitation.Email,
FullName: invitation.FullName,
Role: invitation.Role,
ExpiresAt: invitation.ExpiresAt,
AcceptedAt: invitation.AcceptedAt,
CreatedAt: invitation.CreatedAt,
OrganizationID: invitation.OrganizationID,
}
for _, org := range organizations {
if org.ID == invitation.OrganizationID {
userInvitation.Organization = OrganizationSummary{
ID: org.ID,
Name: org.Name,
}
}
}
userInvitations = append(userInvitations, userInvitation)
}
return nil
},
)
if err != nil {
return nil, err
}
return userInvitations, nil
}
func (s *TenantAuthzService) GetOrganizationByInvitationID(
ctx context.Context,
invitationID gid.GID,
) (*coredata.Organization, error) {
var organization coredata.Organization
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
var invitation coredata.Invitation
if err := invitation.LoadByID(ctx, conn, s.scope, invitationID); err != nil {
return fmt.Errorf("cannot load invitation: %w", err)
}
if err := organization.LoadByID(ctx, conn, s.scope, invitation.OrganizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return &organization, nil
}
func (s *TenantAuthzService) AddUserToOrganization(
ctx context.Context,
userID gid.GID,
orgID gid.GID,
role coredata.MembershipRole,
) error {
now := time.Now()
membershipID := gid.New(s.scope.GetTenantID(), coredata.MembershipEntityType)
membership := &coredata.Membership{
ID: membershipID,
UserID: userID,
OrganizationID: orgID,
Role: role,
CreatedAt: now,
UpdatedAt: now,
}
return s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := membership.Create(ctx, conn, s.scope); err != nil {
return fmt.Errorf("cannot add user to organization: %w", err)
}
return nil
},
)
}
func (s *TenantAuthzService) GetInvitationsByOrganizationID(
ctx context.Context,
orgID gid.GID,
cursor *page.Cursor[coredata.InvitationOrderField],
filter *coredata.InvitationFilter,
) (*page.Page[*coredata.Invitation, coredata.InvitationOrderField], error) {
var invitations coredata.Invitations
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := invitations.LoadByOrganizationID(ctx, conn, s.scope, orgID, cursor, filter); err != nil {
return fmt.Errorf("cannot load organization invitations: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(invitations, cursor), nil
}
func (s *TenantAuthzService) CountOrganizationInvitations(
ctx context.Context,
orgID gid.GID,
filter *coredata.InvitationFilter,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) (err error) {
var invitations coredata.Invitations
count, err = invitations.CountByOrganizationID(ctx, conn, s.scope, orgID, filter)
if err != nil {
return fmt.Errorf("cannot count organization invitations: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s *TenantAuthzService) GetInvitationByID(
ctx context.Context,
invitationID gid.GID,
) (*coredata.Invitation, error) {
invitation := &coredata.Invitation{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := invitation.LoadByID(ctx, conn, s.scope, invitationID); err != nil {
return fmt.Errorf("cannot load invitation: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return invitation, nil
}
func (s *TenantAuthzService) DeleteInvitation(
ctx context.Context,
invitationID gid.GID,
) error {
return s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
invitation := &coredata.Invitation{}
if err := invitation.LoadByID(ctx, conn, s.scope, invitationID); err != nil {
return fmt.Errorf("cannot load invitation: %w", err)
}
if err := invitation.Delete(ctx, conn, s.scope); err != nil {
return fmt.Errorf("cannot delete invitation: %w", err)
}
return nil
},
)
}
func (s *TenantAuthzService) GetMembershipByUserAndOrganizationID(
ctx context.Context,
userID gid.GID,
orgID gid.GID,
) (*coredata.Membership, error) {
membership := &coredata.Membership{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := membership.LoadByUserAndOrg(ctx, conn, s.scope, userID, orgID); err != nil {
return fmt.Errorf("cannot load membership: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return membership, nil
}
func (s *TenantAuthzService) GetMembershipsByOrganizationID(
ctx context.Context,
orgID gid.GID,
cursor *page.Cursor[coredata.MembershipOrderField],
) (*page.Page[*coredata.Membership, coredata.MembershipOrderField], error) {
var memberships coredata.Memberships
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := memberships.LoadByOrganizationID(ctx, conn, s.scope, orgID, cursor); err != nil {
return fmt.Errorf("cannot load organization memberships: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(memberships, cursor), nil
}
func (s *TenantAuthzService) CountOrganizationMemberships(
ctx context.Context,
orgID gid.GID,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
var memberships coredata.Memberships
var err error
count, err = memberships.CountByOrganizationID(ctx, conn, s.scope, orgID)
return err
},
)
if err != nil {
return 0, fmt.Errorf("cannot count memberships: %w", err)
}
return count, nil
}
func (s *TenantAuthzService) CountOrganizationUsers(
ctx context.Context,
orgID gid.GID,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) (err error) {
var users coredata.Users
count, err = users.CountByOrganizationID(ctx, conn, s.scope, orgID)
if err != nil {
return fmt.Errorf("cannot count organization users: %w", err)
}
return nil
},
)
if err != nil {
return 0, fmt.Errorf("cannot count users: %w", err)
}
return count, nil
}
func (s *TenantAuthzService) GetUserRoleInOrganization(
ctx context.Context,
userID gid.GID,
orgID gid.GID,
) (coredata.MembershipRole, error) {
membership := &coredata.Membership{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := membership.LoadByUserAndOrg(ctx, conn, s.scope, userID, orgID); err != nil {
return fmt.Errorf("cannot get user role: %w", err)
}
return nil
},
)
if err != nil {
return "", err
}
return membership.Role, nil
}
func (s *TenantAuthzService) RemoveMemberFromOrganization(
ctx context.Context,
orgID gid.GID,
memberID gid.GID,
) error {
membership := &coredata.Membership{}
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
if err := membership.LoadByID(ctx, tx, s.scope, memberID); err != nil {
return fmt.Errorf("cannot load membership: %w", err)
}
if membership.OrganizationID != orgID {
return fmt.Errorf("membership does not belong to organization")
}
if err := membership.Delete(ctx, tx, s.scope); err != nil {
return fmt.Errorf("cannot delete membership: %w", err)
}
return nil
},
)
}
func (s *TenantAuthzService) UpdateMembershipRole(
ctx context.Context,
orgID gid.GID,
memberID gid.GID,
newRole coredata.MembershipRole,
) (*coredata.Membership, error) {
membership := &coredata.Membership{}
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
if err := membership.LoadByID(ctx, tx, s.scope, memberID); err != nil {
return fmt.Errorf("cannot load membership: %w", err)
}
if membership.OrganizationID != orgID {
return fmt.Errorf("membership does not belong to organization")
}
// If the new role cannot create API keys, delete all related API key memberships
if newRole != coredata.MembershipRoleOwner {
var apiKeyMemberships coredata.UserAPIKeyMemberships
if err := apiKeyMemberships.LoadByMembershipID(ctx, tx, s.scope, memberID); err != nil {
return fmt.Errorf("cannot load api key memberships: %w", err)
}
for _, apiKeyMembership := range apiKeyMemberships {
if err := apiKeyMembership.Delete(ctx, tx, s.scope); err != nil {
return fmt.Errorf("cannot delete api key membership: %w", err)
}
}
}
membership.Role = newRole
membership.UpdatedAt = time.Now()
if err := membership.Update(ctx, tx, s.scope); err != nil {
return fmt.Errorf("cannot update membership role: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return membership, nil
}
func (s *TenantAuthzService) InviteUserToOrganization(
ctx context.Context,
organizationID gid.GID,
emailAddress mail.Addr,
fullName string,
role coredata.MembershipRole,
) (*coredata.Invitation, error) {
var invitation *coredata.Invitation
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
user := &coredata.User{}
userExists := true
if err := user.LoadByEmail(ctx, tx, emailAddress); err != nil {
var userNotFound *coredata.ErrUserNotFound
if errors.As(err, &userNotFound) {
userExists = false
} else {
return fmt.Errorf("cannot check if user exists: %w", err)
}
}
organization := &coredata.Organization{}
if err := organization.LoadByID(ctx, tx, s.scope, organizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
invitationID := gid.New(s.scope.GetTenantID(), coredata.InvitationEntityType)
now := time.Now()
invitation = &coredata.Invitation{
ID: invitationID,
OrganizationID: organizationID,
Email: emailAddress,
FullName: fullName,
Role: role,
ExpiresAt: now.Add(s.invitationTokenValidity),
CreatedAt: now,
}
var err error
var invitationURL string
var recipientName string
if userExists {
recipientName = user.FullName
invitationURL = s.baseURL + "/"
} else {
recipientName = fullName
invitationData := coredata.InvitationData{
InvitationID: invitationID,
OrganizationID: organizationID,
Email: emailAddress,
FullName: fullName,
Role: role,
}
invitationToken, err := statelesstoken.NewToken(
s.tokenSecret,
TokenTypeOrganizationInvitation,
s.invitationTokenValidity,
invitationData,
)
if err != nil {
return fmt.Errorf("cannot generate invitation token: %w", err)
}
invitationURL = fmt.Sprintf("%s/auth/signup-from-invitation?token=%s&fullName=%s", s.baseURL, invitationToken, url.QueryEscape(fullName))
}
subject, textBody, htmlBody, err := emails.RenderInvitation(
s.baseURL,
recipientName,
organization.Name,
invitationURL,
)
if err != nil {
return fmt.Errorf("cannot render invitation email: %w", err)
}
email := coredata.NewEmail(
fullName,
emailAddress,
subject,
textBody,
htmlBody,
)
if err := email.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert email: %w", err)
}
if err := invitation.Create(ctx, tx, s.scope); err != nil {
return fmt.Errorf("cannot create invitation: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return invitation, nil
}
func (s *TenantAuthzService) EnsureSAMLMembership(
ctx context.Context,
userID gid.GID,
organizationID gid.GID,
role *coredata.MembershipRole,
) error {
now := time.Now()
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
var membership coredata.Membership
err := membership.LoadByUserAndOrg(ctx, tx, s.scope, userID, organizationID)
if err != nil {
if _, ok := err.(coredata.ErrMembershipNotFound); !ok {
return fmt.Errorf("cannot load membership: %w", err)
}
membershipRole := coredata.MembershipRoleViewer
if role != nil {
membershipRole = *role
}
membershipID := gid.New(s.scope.GetTenantID(), coredata.MembershipEntityType)
membership = coredata.Membership{
ID: membershipID,
UserID: userID,
OrganizationID: organizationID,
Role: membershipRole,
CreatedAt: now,
UpdatedAt: now,
}
if err := membership.Create(ctx, tx, s.scope); err != nil {
return fmt.Errorf("cannot create membership: %w", err)
}
return nil
}
if role != nil && membership.Role != *role {
membership.Role = *role
membership.UpdatedAt = now
if err := membership.Update(ctx, tx, s.scope); err != nil {
return fmt.Errorf("cannot update membership role: %w", err)
}
}
return nil
},
)
}
func (s *TenantAuthzService) Authorize(
ctx context.Context,
user *coredata.User,
apiKey *coredata.UserAPIKey,
entityGID gid.GID,
action Action,
) error {
requiredRoles := GetPermissionsForAction(entityGID.EntityType(), action)
if requiredRoles == nil {
entityModel, _ := coredata.EntityModel(entityGID.EntityType())
return &PermissionDeniedError{
Message: fmt.Sprintf("no permissions defined for action %s on entity %s", action, entityModel),
}
}
role, err := s.GetUserOrAPIKeyRole(ctx, user, apiKey, entityGID)
if err != nil {
return fmt.Errorf("cannot get user or API key role: %w", err)
}
if !slices.Contains(requiredRoles, role) {
return &PermissionDeniedError{
Message: fmt.Sprintf("role %s not authorized for action %s, requires one of %v", role, action, requiredRoles),
}
}
return nil
}
func (s *TenantAuthzService) CanAssignRole(
ctx context.Context,
user *coredata.User,
apiKey *coredata.UserAPIKey,
entityGID gid.GID,
targetRole coredata.MembershipRole,
) error {
currentRole, err := s.GetUserOrAPIKeyRole(ctx, user, apiKey, entityGID)
if err != nil {
return fmt.Errorf("cannot get user or API key role: %w", err)
}
if currentRole == RoleOwner || currentRole == RoleFull {
return nil
}
if currentRole == RoleAdmin {
if targetRole == coredata.MembershipRoleOwner {
return &PermissionDeniedError{Message: "admin users cannot assign owner role"}
}
return nil
}
return &PermissionDeniedError{Message: fmt.Sprintf("role %s cannot assign roles", currentRole)}
}
func (s *TenantAuthzService) GetUserOrAPIKeyRole(
ctx context.Context,
user *coredata.User,
apiKey *coredata.UserAPIKey,
entityGID gid.GID,
) (Role, error) {
var role Role
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if user != nil {
membership := &coredata.Membership{}
if err := membership.LoadRoleByUserAndEntityID(ctx, conn, s.scope, user.ID, entityGID); err != nil {
return fmt.Errorf("cannot get user role: %w", err)
}
role = Role(membership.Role.String())
return nil
}
if apiKey != nil {
apiKeyMembership := &coredata.UserAPIKeyMembership{}
if err := apiKeyMembership.LoadRoleByAPIKeyAndEntityID(ctx, conn, s.scope, apiKey.ID, entityGID); err != nil {
return fmt.Errorf("cannot get API key role: %w", err)
}
role = Role(apiKeyMembership.Role.String())
return nil
}
return fmt.Errorf("no user or API key provided")
},
)
if err != nil {
return "", err
}
return role, nil
}

View File

@@ -109,10 +109,10 @@ func (b *BaseURL) Port() string {
// URLBuilder provides a fluent interface for building URLs.
type URLBuilder struct {
base *BaseURL
path string
query url.Values
err error
base *BaseURL
path string
query url.Values
err error
}
// WithPath returns a URLBuilder with the specified path.
@@ -224,3 +224,12 @@ func (b *BaseURL) MarshalText() ([]byte, error) {
}
return []byte(b.raw), nil
}
func (b *URLBuilder) URL() url.URL {
return url.URL{
Scheme: b.base.Scheme(),
Host: b.base.Host(),
Path: b.path,
RawQuery: b.query.Encode(),
}
}

View File

@@ -0,0 +1,120 @@
// 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 bearertoken parses Bearer tokens according to RFC 6750.
//
// The grammar is defined as:
//
// b64token = 1*( ALPHA / DIGIT / "-" / "." / "_" / "~" / "+" / "/" ) *"="
// credentials = "Bearer" 1*SP b64token
package bearertoken
import (
"errors"
"strings"
)
var (
// ErrInvalidCredentials is returned when the credentials string is malformed.
ErrInvalidCredentials = errors.New("invalid bearer credentials")
// ErrMissingToken is returned when the token part is empty.
ErrMissingToken = errors.New("missing bearer token")
// ErrInvalidToken is returned when the token contains invalid characters.
ErrInvalidToken = errors.New("invalid bearer token")
)
const (
scheme = "Bearer"
)
// Parse extracts the b64token from a Bearer credentials string.
// The input must follow the format: "Bearer" 1*SP b64token
func Parse(credentials string) (string, error) {
if len(credentials) <= len(scheme) {
return "", ErrInvalidCredentials
}
if !strings.EqualFold(credentials[:len(scheme)], scheme) {
return "", ErrInvalidCredentials
}
rest := credentials[len(scheme):]
if len(rest) == 0 || rest[0] != ' ' {
return "", ErrInvalidCredentials
}
// Skip all spaces (1*SP)
token := strings.TrimLeft(rest, " ")
if token == "" {
return "", ErrMissingToken
}
if !isValidToken(token) {
return "", ErrInvalidToken
}
return token, nil
}
// isValidToken checks if the given string is a valid b64token.
// A valid b64token consists of 1 or more characters from the set
// [A-Za-z0-9-._~+/] followed by zero or more '=' characters.
func isValidToken(token string) bool {
if len(token) == 0 {
return false
}
// Find where the padding starts (if any)
paddingStart := strings.IndexByte(token, '=')
if paddingStart == -1 {
paddingStart = len(token)
}
// Must have at least one non-padding character
if paddingStart == 0 {
return false
}
// Validate the base part (before padding)
for i := 0; i < paddingStart; i++ {
if !isB64Char(token[i]) {
return false
}
}
// Validate padding (only '=' allowed after first '=')
for i := paddingStart; i < len(token); i++ {
if token[i] != '=' {
return false
}
}
return true
}
// isB64Char returns true if c is a valid b64token character (excluding padding).
// Valid characters: ALPHA / DIGIT / "-" / "." / "_" / "~" / "+" / "/"
func isB64Char(c byte) bool {
return (c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') ||
c == '-' ||
c == '.' ||
c == '_' ||
c == '~' ||
c == '+' ||
c == '/'
}

View File

@@ -0,0 +1,298 @@
// 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 bearertoken
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParse(t *testing.T) {
t.Parallel()
tests := []struct {
name string
credentials string
wantToken string
wantErr error
}{
// Valid credentials
{
name: "valid simple token",
credentials: "Bearer abc123",
wantToken: "abc123",
wantErr: nil,
},
{
name: "valid uppercase token",
credentials: "Bearer ABCXYZ",
wantToken: "ABCXYZ",
wantErr: nil,
},
{
name: "valid digits only token",
credentials: "Bearer 0123456789",
wantToken: "0123456789",
wantErr: nil,
},
{
name: "valid base64 token with single padding",
credentials: "Bearer dXNlcm5hbWU6cGFzc3dvcmQ=",
wantToken: "dXNlcm5hbWU6cGFzc3dvcmQ=",
wantErr: nil,
},
{
name: "valid base64 token with double padding",
credentials: "Bearer YWJj==",
wantToken: "YWJj==",
wantErr: nil,
},
{
name: "valid token with all special chars",
credentials: "Bearer abc-._~+/123",
wantToken: "abc-._~+/123",
wantErr: nil,
},
{
name: "valid token with hyphen",
credentials: "Bearer abc-def",
wantToken: "abc-def",
wantErr: nil,
},
{
name: "valid token with dot",
credentials: "Bearer abc.def",
wantToken: "abc.def",
wantErr: nil,
},
{
name: "valid token with underscore",
credentials: "Bearer abc_def",
wantToken: "abc_def",
wantErr: nil,
},
{
name: "valid token with tilde",
credentials: "Bearer abc~def",
wantToken: "abc~def",
wantErr: nil,
},
{
name: "valid token with plus",
credentials: "Bearer abc+def",
wantToken: "abc+def",
wantErr: nil,
},
{
name: "valid token with slash",
credentials: "Bearer abc/def",
wantToken: "abc/def",
wantErr: nil,
},
{
name: "valid token with multiple spaces after scheme",
credentials: "Bearer token123",
wantToken: "token123",
wantErr: nil,
},
{
name: "valid jwt-like token",
credentials: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U",
wantToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U",
wantErr: nil,
},
{
name: "valid single char token",
credentials: "Bearer a",
wantToken: "a",
wantErr: nil,
},
// Case insensitive scheme
{
name: "lowercase scheme",
credentials: "bearer abc123",
wantToken: "abc123",
wantErr: nil,
},
{
name: "uppercase scheme",
credentials: "BEARER abc123",
wantToken: "abc123",
wantErr: nil,
},
{
name: "mixed case scheme",
credentials: "BeArEr abc123",
wantToken: "abc123",
wantErr: nil,
},
// Invalid credentials (scheme errors)
{
name: "empty string",
credentials: "",
wantToken: "",
wantErr: ErrInvalidCredentials,
},
{
name: "only scheme without space",
credentials: "Bearer",
wantToken: "",
wantErr: ErrInvalidCredentials,
},
{
name: "scheme without space before token",
credentials: "Bearerabc123",
wantToken: "",
wantErr: ErrInvalidCredentials,
},
{
name: "wrong scheme Basic",
credentials: "Basic abc123",
wantToken: "",
wantErr: ErrInvalidCredentials,
},
{
name: "wrong scheme Digest",
credentials: "Digest abc123",
wantToken: "",
wantErr: ErrInvalidCredentials,
},
{
name: "partial scheme",
credentials: "Bear abc123",
wantToken: "",
wantErr: ErrInvalidCredentials,
},
{
name: "scheme with tab instead of space",
credentials: "Bearer\tabc123",
wantToken: "",
wantErr: ErrInvalidCredentials,
},
// Missing token
{
name: "missing token after single space",
credentials: "Bearer ",
wantToken: "",
wantErr: ErrMissingToken,
},
{
name: "missing token after multiple spaces",
credentials: "Bearer ",
wantToken: "",
wantErr: ErrMissingToken,
},
// Invalid token characters
{
name: "invalid char @",
credentials: "Bearer abc@123",
wantToken: "",
wantErr: ErrInvalidToken,
},
{
name: "invalid char #",
credentials: "Bearer abc#123",
wantToken: "",
wantErr: ErrInvalidToken,
},
{
name: "invalid char !",
credentials: "Bearer abc!123",
wantToken: "",
wantErr: ErrInvalidToken,
},
{
name: "invalid char ?",
credentials: "Bearer abc?123",
wantToken: "",
wantErr: ErrInvalidToken,
},
{
name: "invalid char *",
credentials: "Bearer abc*123",
wantToken: "",
wantErr: ErrInvalidToken,
},
{
name: "invalid char space in token",
credentials: "Bearer abc 123",
wantToken: "",
wantErr: ErrInvalidToken,
},
{
name: "invalid char tab in token",
credentials: "Bearer abc\t123",
wantToken: "",
wantErr: ErrInvalidToken,
},
{
name: "invalid char newline in token",
credentials: "Bearer abc\n123",
wantToken: "",
wantErr: ErrInvalidToken,
},
// Invalid padding
{
name: "token starting with equals",
credentials: "Bearer =abc",
wantToken: "",
wantErr: ErrInvalidToken,
},
{
name: "token with equals in middle",
credentials: "Bearer abc=def",
wantToken: "",
wantErr: ErrInvalidToken,
},
{
name: "token only padding",
credentials: "Bearer ==",
wantToken: "",
wantErr: ErrInvalidToken,
},
{
name: "token with char after padding",
credentials: "Bearer abc==def",
wantToken: "",
wantErr: ErrInvalidToken,
},
}
for _, tt := range tests {
t.Run(
tt.name,
func(t *testing.T) {
t.Parallel()
gotToken, gotErr := Parse(tt.credentials)
if tt.wantErr != nil {
require.ErrorIs(t, gotErr, tt.wantErr)
assert.Empty(t, gotToken)
} else {
require.NoError(t, gotErr)
assert.Equal(t, tt.wantToken, gotToken)
}
},
)
}
}

View File

@@ -21,10 +21,10 @@ import (
"maps"
"time"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
@@ -43,24 +43,8 @@ type (
}
Assets []*Asset
ErrAssetNotFound struct {
Identifier string
}
ErrAssetAlreadyExists struct {
message string
}
)
func (e ErrAssetNotFound) Error() string {
return fmt.Sprintf("asset not found: %q", e.Identifier)
}
func (e ErrAssetAlreadyExists) Error() string {
return e.message
}
func (a *Asset) CursorKey(field AssetOrderField) page.CursorKey {
switch field {
case AssetOrderFieldCreatedAt:
@@ -112,7 +96,7 @@ LIMIT 1;
asset, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Asset])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrAssetNotFound{Identifier: assetID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect asset: %w", err)
@@ -162,7 +146,7 @@ LIMIT 1;
asset, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Asset])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrAssetNotFound{Identifier: a.OwnerID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect asset: %w", err)

View File

@@ -21,10 +21,10 @@ import (
"maps"
"time"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
@@ -43,24 +43,8 @@ type (
}
Audits []*Audit
ErrAuditNotFound struct {
Identifier string
}
ErrAuditAlreadyExists struct {
message string
}
)
func (e ErrAuditNotFound) Error() string {
return fmt.Sprintf("audit not found: %q", e.Identifier)
}
func (e ErrAuditAlreadyExists) Error() string {
return e.message
}
func (a *Audit) CursorKey(field AuditOrderField) page.CursorKey {
switch field {
case AuditOrderFieldCreatedAt:
@@ -116,7 +100,7 @@ LIMIT 1;
audit, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Audit])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrAuditNotFound{Identifier: auditID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect audit: %w", err)
@@ -530,7 +514,7 @@ LIMIT 1;
audit, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Audit])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrAuditNotFound{Identifier: reportID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect audit: %w", err)

View File

@@ -44,24 +44,8 @@ type (
}
Controls []*Control
ErrControlNotFound struct {
Identifier string
}
ErrControlAlreadyExists struct {
message string
}
)
func (e ErrControlNotFound) Error() string {
return fmt.Sprintf("control not found: %q", e.Identifier)
}
func (e ErrControlAlreadyExists) Error() string {
return e.message
}
func (c Control) CursorKey(orderBy ControlOrderField) page.CursorKey {
switch orderBy {
case ControlOrderFieldCreatedAt:
@@ -661,7 +645,7 @@ LIMIT 1;
control, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Control])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrControlNotFound{Identifier: fmt.Sprintf("%s:%s", frameworkID, sectionTitle)}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect control: %w", err)
@@ -710,7 +694,7 @@ LIMIT 1;
control, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Control])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrControlNotFound{Identifier: controlID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect control: %w", err)
@@ -778,9 +762,7 @@ VALUES (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "controls_framework_ref_unique" {
return &ErrControlAlreadyExists{
message: fmt.Sprintf("control with framework_id %s and section_title %q already exists", c.FrameworkID, c.SectionTitle),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert control: %w", err)
@@ -848,9 +830,7 @@ WHERE %s
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "controls_framework_ref_unique" {
return &ErrControlAlreadyExists{
message: fmt.Sprintf("control with section_title %q already exists", c.SectionTitle),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot update control: %w", err)

View File

@@ -21,10 +21,10 @@ import (
"maps"
"time"
"go.probo.inc/probo/pkg/gid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
)
type (
@@ -37,17 +37,8 @@ type (
}
ControlDocuments []*ControlDocument
ErrControlDocumentMappingAlreadyExists struct {
ControlID gid.GID
DocumentID gid.GID
}
)
func (e ErrControlDocumentMappingAlreadyExists) Error() string {
return fmt.Sprintf("control %s is already mapped to document %s", e.ControlID, e.DocumentID)
}
func (cp ControlDocument) Insert(
ctx context.Context,
conn pg.Conn,
@@ -84,10 +75,7 @@ VALUES (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "controls_policies_pkey" {
return &ErrControlDocumentMappingAlreadyExists{
ControlID: cp.ControlID,
DocumentID: cp.DocumentID,
}
return ErrResourceAlreadyExists
}
}

View File

@@ -52,24 +52,8 @@ type (
}
CustomDomains []*CustomDomain
ErrCustomDomainNotFound struct {
Identifier string
}
ErrCustomDomainAlreadyExists struct {
message string
}
)
func (e ErrCustomDomainNotFound) Error() string {
return fmt.Sprintf("custom domain not found: %q", e.Identifier)
}
func (e ErrCustomDomainAlreadyExists) Error() string {
return e.message
}
func NewCustomDomain(tenantID gid.TenantID, domain string) *CustomDomain {
now := time.Now()
return &CustomDomain{
@@ -385,9 +369,7 @@ INSERT INTO custom_domains (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "custom_domains_domain_key" {
return &ErrCustomDomainAlreadyExists{
message: fmt.Sprintf("custom domain with domain %q already exists", cd.Domain),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert custom domain: %w", err)

View File

@@ -43,24 +43,8 @@ type (
}
Documents []*Document
ErrDocumentNotFound struct {
Identifier string
}
ErrDocumentAlreadyExists struct {
message string
}
)
func (e ErrDocumentNotFound) Error() string {
return fmt.Sprintf("document not found: %q", e.Identifier)
}
func (e ErrDocumentAlreadyExists) Error() string {
return e.message
}
func (p Document) CursorKey(orderBy DocumentOrderField) page.CursorKey {
switch orderBy {
case DocumentOrderFieldCreatedAt:
@@ -114,7 +98,7 @@ LIMIT 1;
document, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Document])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrDocumentNotFound{Identifier: documentID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect document: %w", err)
@@ -168,7 +152,7 @@ LIMIT 1;
document, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Document])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrDocumentNotFound{Identifier: documentID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect document: %w", err)

View File

@@ -46,32 +46,8 @@ type (
}
DocumentVersions []*DocumentVersion
ErrDocumentVersionNotFound struct {
Identifier string
}
ErrDocumentVersionAlreadyExists struct {
message string
}
ErrDocumentVersionNoChanges struct {
Message string
}
)
func (e ErrDocumentVersionNotFound) Error() string {
return fmt.Sprintf("document version not found: %q", e.Identifier)
}
func (e ErrDocumentVersionAlreadyExists) Error() string {
return e.message
}
func (e ErrDocumentVersionNoChanges) Error() string {
return e.Message
}
func (p *DocumentVersions) LoadByDocumentID(
ctx context.Context,
conn pg.Conn,
@@ -245,15 +221,8 @@ VALUES (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" {
if pgErr.ConstraintName == "document_versions_document_id_version_number_key" {
return &ErrDocumentVersionAlreadyExists{
message: fmt.Sprintf("document version with document_id %s and version_number %d already exists", p.DocumentID, p.VersionNumber),
}
}
if pgErr.ConstraintName == "document_one_draft_version_idx" {
return &ErrDocumentVersionAlreadyExists{
message: fmt.Sprintf("document %s already has a draft version", p.DocumentID),
}
if pgErr.ConstraintName == "document_versions_document_id_version_number_key" || pgErr.ConstraintName == "document_one_draft_version_idx" {
return ErrResourceAlreadyExists
}
}
}

View File

@@ -50,30 +50,7 @@ type (
}
DocumentVersionSignaturesWithPeople []*DocumentVersionSignatureWithPeople
ErrDocumentVersionSignatureNotFound struct {
Identifier string
}
ErrDocumentVersionSignatureAlreadyExists struct {
message string
}
ErrDocumentVersionSignatureAlreadySigned struct{}
)
func (e ErrDocumentVersionSignatureNotFound) Error() string {
return fmt.Sprintf("document version signature not found: %q", e.Identifier)
}
func (e ErrDocumentVersionSignatureAlreadyExists) Error() string {
return e.message
}
func (e ErrDocumentVersionSignatureAlreadySigned) Error() string {
return "document version already signed"
}
func (pvs DocumentVersionSignature) CursorKey(orderBy DocumentVersionSignatureOrderField) page.CursorKey {
switch orderBy {
case DocumentVersionSignatureOrderFieldCreatedAt:
@@ -225,9 +202,7 @@ INSERT INTO document_version_signatures (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "policy_version_signatures_policy_version_id_signed_by_key" {
return &ErrDocumentVersionSignatureAlreadyExists{
message: fmt.Sprintf("document version signature with document_version_id %s and signed_by %s already exists", pvs.DocumentVersionID, pvs.SignedBy),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert document version signature: %w", err)

11
pkg/coredata/errors.go Normal file
View File

@@ -0,0 +1,11 @@
package coredata
import (
"errors"
)
var (
ErrResourceNotFound = errors.New("resource not found")
ErrResourceAlreadyExists = errors.New("resource already exists")
ErrResourceInUse = errors.New("resource is in use")
)

View File

@@ -45,24 +45,8 @@ type (
}
Evidences []*Evidence
ErrEvidenceNotFound struct {
Identifier string
}
ErrEvidenceAlreadyExists struct {
message string
}
)
func (e ErrEvidenceNotFound) Error() string {
return fmt.Sprintf("evidence not found: %q", e.Identifier)
}
func (e ErrEvidenceAlreadyExists) Error() string {
return e.message
}
func (e Evidence) CursorKey(orderBy EvidenceOrderField) page.CursorKey {
switch orderBy {
case EvidenceOrderFieldCreatedAt:
@@ -192,9 +176,7 @@ VALUES (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "evidences_reference_id_key" {
return &ErrEvidenceAlreadyExists{
message: fmt.Sprintf("evidence with task_id %s and reference_id %q already exists", e.TaskID, e.ReferenceID),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert evidence: %w", err)

View File

@@ -0,0 +1,11 @@
package coredata
type (
ExpireReason string
)
const (
ExpireReasonIdleTimeout ExpireReason = "idle_timeout"
ExpireReasonRevoked ExpireReason = "revoked"
ExpireReasonClosed ExpireReason = "closed"
)

View File

@@ -42,24 +42,8 @@ type (
}
Files []*File
ErrFileNotFound struct {
Identifier string
}
ErrFileAlreadyExists struct {
message string
}
)
func (e ErrFileNotFound) Error() string {
return fmt.Sprintf("file not found: %q", e.Identifier)
}
func (e ErrFileAlreadyExists) Error() string {
return e.message
}
func (f *File) LoadByID(
ctx context.Context,
conn pg.Conn,
@@ -100,7 +84,7 @@ LIMIT 1;
file, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[File])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrFileNotFound{Identifier: fileID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect file: %w", err)
@@ -165,9 +149,7 @@ VALUES (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "files_file_key_key" {
return &ErrFileAlreadyExists{
message: fmt.Sprintf("file with file_key %q already exists", f.FileKey),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert file: %w", err)

View File

@@ -42,33 +42,8 @@ type (
}
Frameworks []*Framework
ErrFrameworkNotFound struct {
Identifier string
}
ErrFrameworkAlreadyExists struct {
message string
}
ErrFrameworkReferenceIDAlreadyExists struct {
ReferenceID string
OrganizationID gid.GID
}
)
func (e ErrFrameworkNotFound) Error() string {
return fmt.Sprintf("framework not found: %q", e.Identifier)
}
func (e ErrFrameworkAlreadyExists) Error() string {
return e.message
}
func (e ErrFrameworkReferenceIDAlreadyExists) Error() string {
return fmt.Sprintf("framework with reference ID %q already exists for organization %s", e.ReferenceID, e.OrganizationID)
}
func (f *Framework) CursorKey(orderBy FrameworkOrderField) page.CursorKey {
switch orderBy {
case FrameworkOrderFieldCreatedAt:
@@ -192,7 +167,7 @@ LIMIT 1;
framework, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Framework])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrFrameworkNotFound{Identifier: referenceID}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect framework: %w", err)
@@ -240,7 +215,7 @@ LIMIT 1;
framework, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Framework])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrFrameworkNotFound{Identifier: frameworkID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect framework: %w", err)
@@ -302,10 +277,7 @@ VALUES (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "frameworks_org_ref_unique" {
return &ErrFrameworkReferenceIDAlreadyExists{
ReferenceID: f.ReferenceID,
OrganizationID: f.OrganizationID,
}
return ErrResourceAlreadyExists
}
}

View File

@@ -42,24 +42,8 @@ type (
}
Invitations []*Invitation
InvitationData struct {
InvitationID gid.GID `json:"invitation_id"`
OrganizationID gid.GID `json:"organization_id"`
Email mail.Addr `json:"email"`
FullName string `json:"full_name"`
Role MembershipRole `json:"role"`
}
ErrInvitationNotFound struct {
ID string
}
)
func (e ErrInvitationNotFound) Error() string {
return fmt.Sprintf("invitation not found: %s", e.ID)
}
func (i Invitation) CursorKey(orderBy InvitationOrderField) page.CursorKey {
switch orderBy {
case InvitationOrderFieldFullName:
@@ -83,7 +67,7 @@ func (i Invitation) CursorKey(orderBy InvitationOrderField) page.CursorKey {
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
}
func (i *Invitation) Create(ctx context.Context, conn pg.Conn, scope Scoper) error {
func (i *Invitation) Insert(ctx context.Context, conn pg.Conn, scope Scoper) error {
query := `
INSERT INTO
authz_invitations (
@@ -170,8 +154,9 @@ WHERE
invitation, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Invitation])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrInvitationNotFound{ID: id.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect invitation: %w", err)
}
@@ -204,25 +189,25 @@ WHERE
}
if result.RowsAffected() == 0 {
return ErrInvitationNotFound{ID: i.ID.String()}
return ErrResourceNotFound
}
return nil
}
func (i *Invitation) Delete(ctx context.Context, conn pg.Conn, scope Scoper) error {
func (i *Invitation) Delete(ctx context.Context, conn pg.Conn, scope Scoper, invitationID gid.GID) error {
query := `
DELETE FROM
authz_invitations
WHERE
id = @id
AND %s
%s
AND id = @invitation_id
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"id": i.ID,
"invitation_id": invitationID,
}
maps.Copy(args, scope.SQLArguments())
@@ -232,13 +217,13 @@ WHERE
}
if result.RowsAffected() == 0 {
return ErrInvitationNotFound{ID: i.ID.String()}
return ErrResourceNotFound
}
return nil
}
func (i *Invitations) LoadByEmail(
func (i *Invitations) LoadByIdentityID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
@@ -386,7 +371,7 @@ WHERE
func (i *Invitations) CountByEmail(
ctx context.Context,
conn pg.Conn,
email string,
email mail.Addr,
filter *InvitationFilter,
) (int, error) {
q := `

View File

@@ -43,24 +43,8 @@ type (
}
Measures []*Measure
ErrMeasureNotFound struct {
Identifier string
}
ErrMeasureAlreadyExists struct {
message string
}
)
func (e ErrMeasureNotFound) Error() string {
return fmt.Sprintf("measure not found: %q", e.Identifier)
}
func (e ErrMeasureAlreadyExists) Error() string {
return e.message
}
func (m Measure) CursorKey(orderBy MeasureOrderField) page.CursorKey {
switch orderBy {
case MeasureOrderFieldCreatedAt:
@@ -414,7 +398,7 @@ LIMIT 1;
measure, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Measure])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrMeasureNotFound{Identifier: measureID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect measures: %w", err)
@@ -552,9 +536,7 @@ VALUES (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "mitigations_org_ref_unique" {
return &ErrMeasureAlreadyExists{
message: fmt.Sprintf("measure with organization_id %s and reference_id %q already exists", m.OrganizationID, m.ReferenceID),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert measure: %w", err)

View File

@@ -39,24 +39,8 @@ type (
}
Meetings []*Meeting
ErrMeetingNotFound struct {
Identifier string
}
ErrMeetingAlreadyExists struct {
message string
}
)
func (e ErrMeetingNotFound) Error() string {
return fmt.Sprintf("meeting not found: %s", e.Identifier)
}
func (e ErrMeetingAlreadyExists) Error() string {
return e.message
}
func (m Meeting) CursorKey(orderBy MeetingOrderField) page.CursorKey {
switch orderBy {
case MeetingOrderFieldCreatedAt:
@@ -106,7 +90,7 @@ LIMIT 1;
meeting, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Meeting])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrMeetingNotFound{Identifier: meetingID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect meeting: %w", err)
@@ -271,7 +255,7 @@ WHERE %s
}
if result.RowsAffected() == 0 {
return &ErrMeetingNotFound{Identifier: m.ID.String()}
return ErrResourceNotFound
}
return nil
@@ -300,7 +284,7 @@ WHERE %s
}
if result.RowsAffected() == 0 {
return &ErrMeetingNotFound{Identifier: m.ID.String()}
return ErrResourceNotFound
}
return nil

View File

@@ -43,26 +43,8 @@ type (
}
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:
@@ -78,7 +60,46 @@ func (m Membership) CursorKey(orderBy MembershipOrderField) page.CursorKey {
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
}
func (m *Membership) Create(ctx context.Context, conn pg.Conn, scope Scoper) error {
func (m *Membership) LoadByUserInOrganization(ctx context.Context, conn pg.Conn, userID gid.GID, organizationID gid.GID) error {
q := `
SELECT
id,
user_id,
organization_id,
role,
created_at,
updated_at
FROM
authz_memberships
WHERE
user_id = @user_id
AND organization_id = @organization_id
`
args := pgx.StrictNamedArgs{
"user_id": userID,
"organization_id": organizationID,
}
rows, err := conn.Query(ctx, q, 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 ErrResourceNotFound
}
return fmt.Errorf("cannot collect membership: %w", err)
}
*m = membership
return nil
}
func (m *Membership) Insert(ctx context.Context, conn pg.Conn, scope Scoper) error {
query := `
INSERT INTO
authz_memberships (
@@ -115,8 +136,9 @@ VALUES (
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return ErrMembershipAlreadyExists{UserID: m.UserID, OrgID: m.OrganizationID}
return ErrResourceAlreadyExists
}
return fmt.Errorf("cannot create membership: %w", err)
}
@@ -178,8 +200,9 @@ JOIN
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 ErrResourceNotFound
}
return fmt.Errorf("cannot collect membership: %w", err)
}
@@ -243,7 +266,7 @@ LIMIT 1;
defer rows.Close()
if !rows.Next() {
return &ErrMembershipNotFound{UserID: userID, OrgID: entityID}
return ErrResourceNotFound
}
var membership Membership
@@ -269,9 +292,9 @@ func (m *Membership) LoadByUserAndOrg(
conn pg.Conn,
scope Scoper,
userID gid.GID,
orgID gid.GID,
organizationID gid.GID,
) error {
query := `
q := `
WITH mbr AS (
SELECT
am.id,
@@ -302,20 +325,15 @@ JOIN
users u ON mbr.user_id = u.id
`
// Build scope fragment with table alias
scopeFragment := scope.SQLFragment()
// Replace column references with table-qualified versions
scopeFragment = strings.ReplaceAll(scopeFragment, "tenant_id =", "am.tenant_id =")
query = fmt.Sprintf(query, scopeFragment)
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"user_id": userID,
"organization_id": orgID,
"organization_id": organizationID,
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, query, args)
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query membership: %w", err)
}
@@ -323,8 +341,9 @@ JOIN
membership, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Membership])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrMembershipNotFound{UserID: userID, OrgID: orgID}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect membership: %w", err)
}
@@ -359,25 +378,25 @@ WHERE
}
if result.RowsAffected() == 0 {
return ErrMembershipNotFound{UserID: m.UserID, OrgID: m.OrganizationID}
return ErrResourceNotFound
}
return nil
}
func (m *Membership) Delete(ctx context.Context, conn pg.Conn, scope Scoper) error {
func (m *Membership) Delete(ctx context.Context, conn pg.Conn, scope Scoper, membershipID gid.GID) error {
query := `
DELETE FROM
authz_memberships
WHERE
id = @id
AND %s
%s
AND id = @membership_id
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"id": m.ID,
"membership_id": membershipID,
}
maps.Copy(args, scope.SQLArguments())
@@ -387,7 +406,7 @@ WHERE
}
if result.RowsAffected() == 0 {
return ErrMembershipNotFound{UserID: m.UserID, OrgID: m.OrganizationID}
return ErrResourceNotFound
}
return nil
@@ -398,6 +417,7 @@ func (m *Memberships) LoadByUserID(
conn pg.Conn,
scope Scoper,
userID gid.GID,
cursor *page.Cursor[MembershipOrderField],
) error {
query := `
WITH mbr AS (
@@ -552,3 +572,29 @@ WHERE
}
return count, nil
}
func (m *Memberships) CountByUserID(
ctx context.Context,
conn pg.Conn,
userID gid.GID,
) (int, error) {
query := `
SELECT
COUNT(*)
FROM
authz_memberships
WHERE
user_id = @user_id
`
args := pgx.StrictNamedArgs{
"user_id": userID,
}
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
}

View File

@@ -0,0 +1,9 @@
CREATE TYPE session_expire_reason AS ENUM (
'idle_timeout',
'revoked',
'closed'
);
ALTER TABLE sessions ADD COLUMN expire_reason session_expire_reason;
UPDATE sessions SET expire_reason = 'idle_timeout' WHERE expired_at < NOW();

View File

@@ -0,0 +1,2 @@
ALTER TABLE sessions ADD COLUMN user_agent TEXT DEFAULT 'SESSION_CREATED_BEFORE_USER_AGENT_COLUMN_ADDED';
ALTER TABLE sessions ADD COLUMN ip_address INET DEFAULT '::1';

View File

@@ -0,0 +1,8 @@
ALTER TABLE sessions ADD COLUMN tenant_id TEXT;
ALTER TABLE sessions ADD COLUMN parent_session_id TEXT REFERENCES sessions(id);
ALTER TABLE sessions ADD CONSTRAINT session_tenant_check CHECK (
(parent_session_id IS NULL AND tenant_id IS NULL) OR
(parent_session_id IS NOT NULL AND tenant_id IS NOT NULL)
);

View File

@@ -0,0 +1,2 @@
ALTER TABLE auth_user_api_keys ADD COLUMN expire_reason TEXT;

View File

@@ -44,24 +44,8 @@ type (
}
Organizations []*Organization
ErrOrganizationNotFound struct {
Identifier string
}
ErrOrganizationAlreadyExists struct {
message string
}
)
func (e ErrOrganizationNotFound) Error() string {
return fmt.Sprintf("organization not found: %q", e.Identifier)
}
func (e ErrOrganizationAlreadyExists) Error() string {
return e.message
}
func (o Organization) CursorKey(orderBy OrganizationOrderField) page.CursorKey {
switch orderBy {
case OrganizationOrderFieldName:
@@ -116,7 +100,7 @@ LIMIT 1;
organization, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Organization])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrOrganizationNotFound{Identifier: organizationID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect organization: %w", err)
@@ -442,19 +426,14 @@ WHERE
func (o *Organization) Delete(
ctx context.Context,
conn pg.Conn,
scope Scoper,
organizationID gid.GID,
) error {
q := `
DELETE FROM organizations
WHERE
%s
AND id = @id
WHERE id = @id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"id": o.ID}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args)
if err != nil {
@@ -505,7 +484,7 @@ LIMIT 1
organization, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Organization])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrOrganizationNotFound{Identifier: customDomainID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect organization: %w", err)

View File

@@ -33,16 +33,8 @@ type (
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
ErrOrganizationContextNotFound struct {
Identifier string
}
)
func (e ErrOrganizationContextNotFound) Error() string {
return fmt.Sprintf("organization context not found: %q", e.Identifier)
}
func (oc *OrganizationContext) LoadByOrganizationID(
ctx context.Context,
conn pg.Conn,
@@ -76,7 +68,7 @@ LIMIT 1;
orgContext, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[OrganizationContext])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrOrganizationContextNotFound{Identifier: organizationID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect organization context: %w", err)
@@ -155,7 +147,7 @@ WHERE
}
if result.RowsAffected() == 0 {
return &ErrOrganizationContextNotFound{Identifier: oc.OrganizationID.String()}
return ErrResourceNotFound
}
return nil

View File

@@ -45,32 +45,8 @@ type (
}
Peoples []*People
ErrPeopleNotFound struct {
Identifier string
}
ErrPeopleAlreadyExists struct {
message string
}
ErrPeopleReferenced struct {
message string
}
)
func (e ErrPeopleNotFound) Error() string {
return fmt.Sprintf("people not found: %s", e.Identifier)
}
func (e ErrPeopleAlreadyExists) Error() string {
return e.message
}
func (e ErrPeopleReferenced) Error() string {
return e.message
}
func (p People) CursorKey(orderBy PeopleOrderField) page.CursorKey {
switch orderBy {
case PeopleOrderFieldCreatedAt:
@@ -124,7 +100,7 @@ LIMIT 1;
people, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[People])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrPeopleNotFound{Identifier: peopleID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect people: %w", err)
@@ -175,7 +151,7 @@ LIMIT 1;
people, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[People])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrPeopleNotFound{Identifier: primaryEmailAddress}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect people: %w", err)
@@ -231,7 +207,7 @@ LIMIT 1;
people, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[People])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrPeopleNotFound{Identifier: primaryEmailAddress.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect people: %w", err)
@@ -362,9 +338,7 @@ DELETE FROM peoples WHERE %s AND id = @people_id
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23503" {
return &ErrPeopleReferenced{
message: fmt.Sprintf("person with id %s cannot be deleted because it is referenced by other records", p.ID),
}
return ErrResourceInUse
}
}
return fmt.Errorf("cannot delete person: %w", err)

View File

@@ -79,7 +79,7 @@ LIMIT 1;
report, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Report])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrDocumentNotFound{Identifier: reportID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect report: %w", err)

View File

@@ -57,24 +57,8 @@ type (
RiskSnapshotter interface {
InsertRiskSnapshots(ctx context.Context, conn pg.Conn, scope Scoper, organizationID, snapshotID gid.GID) error
}
ErrRiskNotFound struct {
Identifier string
}
ErrRiskAlreadyExists struct {
message string
}
)
func (e ErrRiskNotFound) Error() string {
return fmt.Sprintf("risk not found: %q", e.Identifier)
}
func (e ErrRiskAlreadyExists) Error() string {
return e.message
}
func (r *Risk) CursorKey(orderBy RiskOrderField) page.CursorKey {
switch orderBy {
case RiskOrderFieldCreatedAt:
@@ -392,7 +376,7 @@ LIMIT 1;
risk, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Risk])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrRiskNotFound{Identifier: riskID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect risk: %w", err)

View File

@@ -16,12 +16,14 @@ package coredata
import (
"context"
"errors"
"fmt"
"time"
"go.probo.inc/probo/pkg/gid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
)
type SAMLAssertion struct {
@@ -31,27 +33,17 @@ type SAMLAssertion struct {
ExpiresAt time.Time `db:"expires_at"`
}
type ErrAssertionAlreadyUsed struct {
AssertionID string
}
func (e ErrAssertionAlreadyUsed) Error() string {
return fmt.Sprintf("assertion ID %q has already been used (replay attack)", e.AssertionID)
}
func (s *SAMLAssertion) Insert(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
query := `
INSERT INTO auth_saml_assertions (id, tenant_id, organization_id, used_at, expires_at)
VALUES (@id, @tenant_id, @organization_id, @used_at, @expires_at)
INSERT INTO auth_saml_assertions (id, organization_id, used_at, expires_at)
VALUES (@id, @organization_id, @used_at, @expires_at)
`
args := pgx.NamedArgs{
"id": s.ID,
"tenant_id": scope.GetTenantID(),
"organization_id": s.OrganizationID,
"used_at": s.UsedAt,
"expires_at": s.ExpiresAt,
@@ -59,6 +51,11 @@ VALUES (@id, @tenant_id, @organization_id, @used_at, @expires_at)
_, err := conn.Exec(ctx, query, args)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" && pgErr.ConstraintName == "auth_saml_assertions_pkey" {
return ErrResourceAlreadyExists
}
return fmt.Errorf("cannot insert saml_assertion: %w", err)
}

View File

@@ -16,6 +16,8 @@ package coredata
import (
"context"
"crypto/x509"
"encoding/pem"
"fmt"
"maps"
"time"
@@ -23,28 +25,55 @@ import (
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type SAMLConfiguration struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
EmailDomain string `db:"email_domain"`
Enabled bool `db:"enabled"`
EnforcementPolicy SAMLEnforcementPolicy `db:"enforcement_policy"`
IdPEntityID string `db:"idp_entity_id"`
IdPSsoURL string `db:"idp_sso_url"`
IdPCertificate string `db:"idp_certificate"`
IdPMetadataURL *string `db:"idp_metadata_url"`
AttributeEmail string `db:"attribute_email"`
AttributeFirstname string `db:"attribute_firstname"`
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"`
UpdatedAt time.Time `db:"updated_at"`
type (
SAMLConfiguration struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
EmailDomain string `db:"email_domain"`
EnforcementPolicy SAMLEnforcementPolicy `db:"enforcement_policy"`
IdPEntityID string `db:"idp_entity_id"`
IdPSsoURL string `db:"idp_sso_url"`
IdPCertificate string `db:"idp_certificate"`
IdPMetadataURL *string `db:"idp_metadata_url"`
AttributeEmail string `db:"attribute_email"`
AttributeFirstname string `db:"attribute_firstname"`
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"`
UpdatedAt time.Time `db:"updated_at"`
}
SAMLConfigurations []*SAMLConfiguration
)
func (s *SAMLConfiguration) CursorKey(orderBy SAMLConfigurationOrderField) page.CursorKey {
switch orderBy {
case SAMLConfigurationOrderFieldCreatedAt:
return page.NewCursorKey(s.ID, s.CreatedAt)
}
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
}
func (s *SAMLConfiguration) GetIdPCertificate() (*x509.Certificate, error) {
block, _ := pem.Decode([]byte(s.IdPCertificate))
if block == nil {
return nil, fmt.Errorf("cannot decode PEM block from IdP certificate")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("cannot parse X.509 certificate: %w", err)
}
return cert, nil
}
func (s *SAMLConfiguration) LoadByOrganizationIDAndEmailDomain(
@@ -154,6 +183,10 @@ LIMIT 1;
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)
}
@@ -173,7 +206,6 @@ INSERT INTO auth_saml_configurations (
tenant_id,
organization_id,
email_domain,
enabled,
enforcement_policy,
idp_entity_id,
idp_sso_url,
@@ -194,7 +226,6 @@ INSERT INTO auth_saml_configurations (
@tenant_id,
@organization_id,
@email_domain,
@enabled,
@enforcement_policy,
@idp_entity_id,
@idp_sso_url,
@@ -218,7 +249,6 @@ INSERT INTO auth_saml_configurations (
"tenant_id": scope.GetTenantID(),
"organization_id": s.OrganizationID,
"email_domain": s.EmailDomain,
"enabled": s.Enabled,
"enforcement_policy": s.EnforcementPolicy,
"idp_entity_id": s.IdPEntityID,
"idp_sso_url": s.IdPSsoURL,
@@ -252,7 +282,6 @@ func (s *SAMLConfiguration) Update(
q := `
UPDATE auth_saml_configurations
SET
enabled = @enabled,
enforcement_policy = @enforcement_policy,
idp_entity_id = @idp_entity_id,
idp_sso_url = @idp_sso_url,
@@ -276,7 +305,6 @@ WHERE
args := pgx.StrictNamedArgs{
"id": s.ID,
"enabled": s.Enabled,
"enforcement_policy": s.EnforcementPolicy,
"idp_entity_id": s.IdPEntityID,
"idp_sso_url": s.IdPSsoURL,
@@ -328,18 +356,17 @@ WHERE
return nil
}
func LoadSAMLConfigurationsByOrganizationID(
func (s *SAMLConfigurations) LoadByOrganizationID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
organizationID gid.GID,
) ([]*SAMLConfiguration, error) {
) error {
q := `
SELECT
id,
organization_id,
email_domain,
enabled,
enforcement_policy,
idp_entity_id,
idp_sso_url,
@@ -370,20 +397,17 @@ ORDER BY email_domain ASC;
rows, err := conn.Query(ctx, q, args)
if err != nil {
return nil, fmt.Errorf("cannot query auth_saml_configurations: %w", err)
return fmt.Errorf("cannot query auth_saml_configurations: %w", err)
}
configs, err := pgx.CollectRows(rows, pgx.RowToStructByName[SAMLConfiguration])
samlConfigurations, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[SAMLConfiguration])
if err != nil {
return nil, fmt.Errorf("cannot collect saml_configurations: %w", err)
return fmt.Errorf("cannot collect saml_configurations: %w", err)
}
result := make([]*SAMLConfiguration, len(configs))
for i := range configs {
result[i] = &configs[i]
}
*s = samlConfigurations
return result, nil
return nil
}
// LoadAllEnabledSAMLConfigurationsByEmailDomain loads all enabled SAML configurations for a given email domain
@@ -398,7 +422,6 @@ SELECT
id,
organization_id,
email_domain,
enabled,
enforcement_policy,
idp_entity_id,
idp_sso_url,
@@ -458,7 +481,6 @@ SELECT
id,
organization_id,
email_domain,
enabled,
enforcement_policy,
idp_entity_id,
idp_sso_url,
@@ -503,3 +525,38 @@ WHERE
return result, nil
}
func (s *SAMLConfigurations) CountByOrganizationID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
organizationID gid.GID,
) (int, error) {
q := `
SELECT
COUNT(*)
FROM
auth_saml_configurations
WHERE
%s
AND organization_id = @organization_id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return 0, fmt.Errorf("cannot query auth_saml_configurations: %w", err)
}
var count int
err = rows.Scan(&count)
if err != nil {
return 0, fmt.Errorf("cannot collect count: %w", err)
}
return count, nil
}

View File

@@ -0,0 +1,40 @@
// 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 (
SAMLConfigurationOrderField string
)
const (
SAMLConfigurationOrderFieldCreatedAt SAMLConfigurationOrderField = "CREATED_AT"
)
func (p SAMLConfigurationOrderField) Column() string {
return string(p)
}
func (p SAMLConfigurationOrderField) String() string {
return string(p)
}
func (p SAMLConfigurationOrderField) MarshalText() ([]byte, error) {
return []byte(p.String()), nil
}
func (p *SAMLConfigurationOrderField) UnmarshalText(text []byte) error {
*p = SAMLConfigurationOrderField(text)
return nil
}

View File

@@ -19,9 +19,9 @@ import (
"fmt"
"time"
"go.probo.inc/probo/pkg/gid"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
)
type SAMLRequest struct {
@@ -31,37 +31,18 @@ type SAMLRequest struct {
ExpiresAt time.Time `db:"expires_at"`
}
type ErrSAMLRequestNotFound struct {
RequestID string
}
func (e ErrSAMLRequestNotFound) Error() string {
return fmt.Sprintf("SAML request ID %q not found", e.RequestID)
}
type ErrSAMLRequestExpired struct {
RequestID string
ExpiresAt time.Time
}
func (e ErrSAMLRequestExpired) Error() string {
return fmt.Sprintf("SAML request ID %q expired at %v", e.RequestID, e.ExpiresAt)
}
func (s *SAMLRequest) Insert(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
query := `
INSERT INTO auth_saml_requests (id, organization_id, tenant_id, created_at, expires_at)
VALUES (@id, @organization_id, @tenant_id, @created_at, @expires_at)
INSERT INTO auth_saml_requests (id, organization_id, created_at, expires_at)
VALUES (@id, @organization_id, @created_at, @expires_at)
`
args := pgx.NamedArgs{
"id": s.ID,
"organization_id": s.OrganizationID,
"tenant_id": scope.GetTenantID(),
"created_at": s.CreatedAt,
"expires_at": s.ExpiresAt,
}
@@ -99,8 +80,9 @@ LIMIT 1
req, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[SAMLRequest])
if err == pgx.ErrNoRows {
return ErrSAMLRequestNotFound{RequestID: requestID}
return ErrResourceNotFound
}
if err != nil {
return fmt.Errorf("cannot collect saml_request: %w", err)
}

View File

@@ -17,8 +17,8 @@ package coredata
import (
"fmt"
"go.probo.inc/probo/pkg/gid"
"github.com/jackc/pgx/v5"
"go.probo.inc/probo/pkg/gid"
)
type (
@@ -62,6 +62,10 @@ func NewScope(tenantID gid.TenantID) *Scope {
}
}
func NewScopeFromObjectID(objectID gid.GID) *Scope {
return NewScope(objectID.TenantID())
}
func (s *Scope) SQLArguments() pgx.StrictNamedArgs {
return pgx.StrictNamedArgs{
"tenant_id": s.tenantID,

View File

@@ -18,26 +18,35 @@ import (
"context"
"errors"
"fmt"
"maps"
"net"
"time"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
Session struct {
ID gid.GID `db:"id"`
UserID gid.GID `db:"user_id"`
Data SessionData `db:"data"`
ExpiredAt time.Time `db:"expired_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
ID gid.GID `db:"id"`
UserID gid.GID `db:"user_id"`
TenantID *gid.TenantID `db:"tenant_id"`
ParentSessionID *gid.GID `db:"parent_session_id"`
Data SessionData `db:"data"`
UserAgent string `db:"user_agent"`
IPAddress net.IP `db:"ip_address"`
ExpireReason *ExpireReason `db:"expire_reason"`
ExpiredAt time.Time `db:"expired_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
Sessions []*Session
SessionData struct {
PasswordAuthenticated bool `json:"password_authenticated"`
PasswordAuthenticated bool `json:"password_authenticated"`
SAMLAuthenticatedOrgs map[string]SAMLAuthInfo `json:"saml_authenticated_orgs,omitempty"`
}
@@ -46,33 +55,39 @@ type (
SAMLConfigID gid.GID `json:"saml_config_id"`
SAMLSubject string `json:"saml_subject"`
}
ErrSessionNotFound struct {
Identifier string
}
ErrSessionAlreadyExists struct {
message string
}
)
func (e ErrSessionNotFound) Error() string {
return fmt.Sprintf("session not found: %q", e.Identifier)
}
func (e ErrSessionAlreadyExists) Error() string {
return e.message
func NewRootSession(userID gid.GID, duration time.Duration) *Session {
return &Session{
ID: gid.New(gid.NilTenant, SessionEntityType),
UserID: userID,
ExpiredAt: time.Now().Add(duration),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
}
func (s Session) CursorKey(orderBy SessionOrderField) page.CursorKey {
switch orderBy {
case SessionOrderFieldCreatedAt:
return page.NewCursorKey(s.ID, s.CreatedAt)
case SessionOrderFieldExpiredAt:
return page.NewCursorKey(s.ID, s.ExpiredAt)
case SessionOrderFieldUpdatedAt:
return page.NewCursorKey(s.ID, s.UpdatedAt)
}
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
}
func (s *Session) IsRootSession() bool {
return s.ParentSessionID == nil
}
func (s *Session) IsChildSession() bool {
return s.ParentSessionID != nil
}
func (s *Session) LoadByID(
ctx context.Context,
conn pg.Conn,
@@ -82,7 +97,12 @@ func (s *Session) LoadByID(
SELECT
id,
user_id,
data,
tenant_id,
data,
parent_session_id,
expire_reason,
user_agent,
ip_address,
expired_at,
created_at,
updated_at
@@ -103,7 +123,7 @@ LIMIT 1;
session, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Session])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrSessionNotFound{Identifier: sessionID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect session: %w", err)
@@ -119,11 +139,16 @@ func (s *Session) Insert(
) error {
q := `
INSERT INTO
sessions (id, user_id, data, expired_at, created_at, updated_at)
sessions (id, user_id, tenant_id, data, parent_session_id, expire_reason, user_agent, ip_address, expired_at, created_at, updated_at)
VALUES (
@session_id,
@user_id,
@tenant_id,
@data,
@parent_session_id,
@expire_reason,
@user_agent,
@ip_address,
@expired_at,
@created_at,
@updated_at
@@ -131,12 +156,17 @@ VALUES (
`
args := pgx.StrictNamedArgs{
"session_id": s.ID,
"user_id": s.UserID,
"data": s.Data,
"expired_at": s.ExpiredAt,
"created_at": s.CreatedAt,
"updated_at": s.UpdatedAt,
"session_id": s.ID,
"user_id": s.UserID,
"tenant_id": s.TenantID,
"data": s.Data,
"parent_session_id": s.ParentSessionID,
"expire_reason": s.ExpireReason,
"user_agent": s.UserAgent,
"ip_address": s.IPAddress,
"expired_at": s.ExpiredAt,
"created_at": s.CreatedAt,
"updated_at": s.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)
@@ -152,36 +182,121 @@ UPDATE sessions
SET
expired_at = @expired_at,
updated_at = @updated_at,
user_agent = @user_agent,
ip_address = @ip_address,
expire_reason = @expire_reason,
data = @data
WHERE
id = @session_id
`
args := pgx.StrictNamedArgs{
"session_id": s.ID,
"data": s.Data,
"expired_at": s.ExpiredAt,
"updated_at": s.UpdatedAt,
"session_id": s.ID,
"user_agent": s.UserAgent,
"ip_address": s.IPAddress,
"expire_reason": s.ExpireReason,
"data": s.Data,
"expired_at": s.ExpiredAt,
"updated_at": s.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)
return err
result, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot update session: %w", err)
}
if result.RowsAffected() == 0 {
return ErrResourceNotFound
}
return nil
}
func DeleteSession(
ctx context.Context,
conn pg.Conn,
sessionID gid.GID,
) error {
func (s *Sessions) LoadByUserID(ctx context.Context, conn pg.Conn, userID gid.GID, cursor *page.Cursor[SessionOrderField]) error {
q := `
DELETE FROM
SELECT
id,
user_id,
tenant_id,
data,
parent_session_id,
expire_reason,
user_agent,
ip_address,
expired_at,
created_at,
updated_at
FROM
sessions
WHERE
id = @session_id
user_id = @user_id
AND %s
`
args := pgx.StrictNamedArgs{"session_id": sessionID}
q = fmt.Sprintf(q, cursor.SQLFragment())
_, err := conn.Exec(ctx, q, args)
return err
args := pgx.StrictNamedArgs{"user_id": userID}
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query sessions: %w", err)
}
sessions, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Session])
if err != nil {
return fmt.Errorf("cannot collect sessions: %w", err)
}
*s = sessions
return nil
}
func (s *Sessions) CountByUserID(ctx context.Context, conn pg.Conn, userID gid.GID) (int, error) {
q := `
SELECT
COUNT(*)
FROM
sessions
WHERE
user_id = @user_id
`
args := pgx.StrictNamedArgs{"user_id": userID}
row := conn.QueryRow(ctx, q, args)
var count int
if err := row.Scan(&count); err != nil {
return 0, fmt.Errorf("cannot scan count: %w", err)
}
return count, nil
}
func (s *Sessions) ExpireAllForUserExceptOneSession(ctx context.Context, conn pg.Conn, userID gid.GID, sessionID gid.GID) (int64, error) {
q := `
UPDATE sessions
SET
expired_at = NOW(),
updated_at = NOW(),
expire_reason = 'revoked'
WHERE
id != @session_id
AND user_id = @user_id
AND expire_reason IS NULL
`
args := pgx.StrictNamedArgs{
"session_id": sessionID,
"user_id": userID,
}
result, err := conn.Exec(ctx, q, args)
if err != nil {
return 0, fmt.Errorf("cannot query sessions: %w", err)
}
return result.RowsAffected(), nil
}

View File

@@ -20,9 +20,20 @@ type (
const (
SessionOrderFieldCreatedAt SessionOrderField = "CREATED_AT"
SessionOrderFieldExpiredAt SessionOrderField = "EXPIRED_AT"
SessionOrderFieldUpdatedAt SessionOrderField = "UPDATED_AT"
)
func (p SessionOrderField) Column() string {
switch p {
case SessionOrderFieldCreatedAt:
return "created_at"
case SessionOrderFieldExpiredAt:
return "expired_at"
case SessionOrderFieldUpdatedAt:
return "updated_at"
}
return string(p)
}

View File

@@ -41,24 +41,8 @@ type (
}
StatesOfApplicability []*StateOfApplicability
ErrStateOfApplicabilityNotFound struct {
Identifier string
}
ErrStateOfApplicabilityAlreadyExists struct {
message string
}
)
func (e ErrStateOfApplicabilityNotFound) Error() string {
return fmt.Sprintf("state of applicability not found: %s", e.Identifier)
}
func (e ErrStateOfApplicabilityAlreadyExists) Error() string {
return e.message
}
func (s StateOfApplicability) CursorKey(orderBy StateOfApplicabilityOrderField) page.CursorKey {
switch orderBy {
case StateOfApplicabilityOrderFieldCreatedAt:
@@ -107,7 +91,7 @@ LIMIT 1;
stateOfApplicability, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[StateOfApplicability])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrStateOfApplicabilityNotFound{Identifier: stateOfApplicabilityID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect state_of_applicability: %w", err)
@@ -246,9 +230,7 @@ VALUES (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" {
return &ErrStateOfApplicabilityAlreadyExists{
message: fmt.Sprintf("state of applicability with name %q already exists", s.Name),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert state_of_applicability: %w", err)
@@ -287,16 +269,14 @@ WHERE %s
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" {
return &ErrStateOfApplicabilityAlreadyExists{
message: fmt.Sprintf("state of applicability with name %q already exists", s.Name),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot update state_of_applicability: %w", err)
}
if result.RowsAffected() == 0 {
return &ErrStateOfApplicabilityNotFound{Identifier: s.ID.String()}
return ErrResourceNotFound
}
return nil
@@ -325,7 +305,7 @@ WHERE %s
}
if result.RowsAffected() == 0 {
return &ErrStateOfApplicabilityNotFound{Identifier: s.ID.String()}
return ErrResourceNotFound
}
return nil

View File

@@ -46,24 +46,8 @@ type (
}
Tasks []*Task
ErrTaskNotFound struct {
Identifier string
}
ErrTaskAlreadyExists struct {
message string
}
)
func (e ErrTaskNotFound) Error() string {
return fmt.Sprintf("task not found: %q", e.Identifier)
}
func (e ErrTaskAlreadyExists) Error() string {
return e.message
}
func (c Task) CursorKey(orderBy TaskOrderField) page.CursorKey {
switch orderBy {
case TaskOrderFieldCreatedAt:
@@ -114,7 +98,7 @@ LIMIT 1;
task, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Task])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrTaskNotFound{Identifier: taskID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect tasks: %w", err)
@@ -185,9 +169,7 @@ VALUES (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "tasks_reference_id_unique" {
return &ErrTaskAlreadyExists{
message: fmt.Sprintf("task with measure_id %s and reference_id %q already exists", c.MeasureID, c.ReferenceID),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert task: %w", err)

View File

@@ -21,11 +21,11 @@ import (
"maps"
"time"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
@@ -41,24 +41,8 @@ type (
}
TrustCenters []*TrustCenter
ErrTrustCenterNotFound struct {
Identifier string
}
ErrTrustCenterAlreadyExists struct {
message string
}
)
func (e ErrTrustCenterNotFound) Error() string {
return fmt.Sprintf("trust center not found: %q", e.Identifier)
}
func (e ErrTrustCenterAlreadyExists) Error() string {
return e.message
}
func (tc *TrustCenter) CursorKey(orderBy TrustCenterOrderField) page.CursorKey {
switch orderBy {
case TrustCenterOrderFieldCreatedAt:
@@ -239,9 +223,7 @@ INSERT INTO trust_centers (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "trust_centers_slug_key" {
return &ErrTrustCenterAlreadyExists{
message: fmt.Sprintf("trust center with slug %q already exists", tc.Slug),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert trust center: %w", err)

View File

@@ -48,24 +48,8 @@ type (
}
TrustCenterAccesses []*TrustCenterAccess
ErrTrustCenterAccessNotFound struct {
Identifier string
}
ErrTrustCenterAccessAlreadyExists struct {
message string
}
)
func (e ErrTrustCenterAccessNotFound) Error() string {
return fmt.Sprintf("trust center access not found: %s", e.Identifier)
}
func (e ErrTrustCenterAccessAlreadyExists) Error() string {
return e.message
}
func (tca *TrustCenterAccess) CursorKey(orderBy TrustCenterAccessOrderField) page.CursorKey {
switch orderBy {
case TrustCenterAccessOrderFieldCreatedAt:
@@ -117,7 +101,7 @@ LIMIT 1;
access, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[TrustCenterAccess])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrTrustCenterAccessNotFound{Identifier: accessID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect trust center access: %w", err)
@@ -175,7 +159,7 @@ LIMIT 1;
access, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[TrustCenterAccess])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrTrustCenterAccessNotFound{Identifier: fmt.Sprintf("trust_center_id=%s, email=%s", trustCenterID, email)}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect trust center access: %w", err)
@@ -235,9 +219,7 @@ INSERT INTO trust_center_accesses (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "trust_center_accesses_trust_center_id_email_key" {
return &ErrTrustCenterAccessAlreadyExists{
message: "trust center access already exists",
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert trust center access: %w", err)

View File

@@ -42,24 +42,8 @@ type (
}
TrustCenterDocumentAccesses []*TrustCenterDocumentAccess
ErrTrustCenterDocumentAccessNotFound struct {
Identifier string
}
ErrTrustCenterDocumentAccessAlreadyExists struct {
message string
}
)
func (e ErrTrustCenterDocumentAccessNotFound) Error() string {
return fmt.Sprintf("trust center document access not found: %s", e.Identifier)
}
func (e ErrTrustCenterDocumentAccessAlreadyExists) Error() string {
return e.message
}
func (tcda *TrustCenterDocumentAccess) CursorKey(orderBy TrustCenterDocumentAccessOrderField) page.CursorKey {
switch orderBy {
case TrustCenterDocumentAccessOrderFieldCreatedAt:
@@ -107,7 +91,7 @@ LIMIT 1;
access, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[TrustCenterDocumentAccess])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrTrustCenterDocumentAccessNotFound{Identifier: accessID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect trust center document access: %w", err)
}
@@ -267,18 +251,10 @@ INSERT INTO trust_center_document_accesses (
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" {
switch pgErr.ConstraintName {
case "trust_center_document_accesse_trust_center_access_id_docume_key":
return &ErrTrustCenterDocumentAccessAlreadyExists{
message: fmt.Sprintf("trust center document access with trust_center_access_id %s and document_id %s already exists", tcda.TrustCenterAccessID, tcda.DocumentID),
}
case "trust_center_document_accesse_trust_center_access_id_report_key":
return &ErrTrustCenterDocumentAccessAlreadyExists{
message: fmt.Sprintf("trust center document access with trust_center_access_id %s and report_id %s already exists", tcda.TrustCenterAccessID, tcda.ReportID),
}
case "trust_center_document_accesses_trust_center_file_id_key":
return &ErrTrustCenterDocumentAccessAlreadyExists{
message: fmt.Sprintf("trust center document access with trust_center_access_id %s and trust_center_file_id %s already exists", tcda.TrustCenterAccessID, tcda.TrustCenterFileID),
}
case "trust_center_document_accesse_trust_center_access_id_docume_key",
"trust_center_document_accesse_trust_center_access_id_report_key",
"trust_center_document_accesses_trust_center_file_id_key":
return ErrResourceAlreadyExists
}
}
}

View File

@@ -43,24 +43,8 @@ type (
}
TrustCenterReferences []*TrustCenterReference
ErrTrustCenterReferenceNotFound struct {
Identifier string
}
ErrTrustCenterReferenceAlreadyExists struct {
message string
}
)
func (e ErrTrustCenterReferenceNotFound) Error() string {
return fmt.Sprintf("trust center reference not found: %q", e.Identifier)
}
func (e ErrTrustCenterReferenceAlreadyExists) Error() string {
return e.message
}
func (t TrustCenterReference) CursorKey(orderBy TrustCenterReferenceOrderField) page.CursorKey {
switch orderBy {
case TrustCenterReferenceOrderFieldRank:
@@ -174,9 +158,7 @@ RETURNING rank;
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "trust_center_references_trust_center_id_rank_key" {
return &ErrTrustCenterReferenceAlreadyExists{
message: fmt.Sprintf("trust center reference with trust_center_id %s and rank already exists", t.TrustCenterID),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot insert trust center reference: %w", err)
@@ -221,7 +203,7 @@ WHERE
}
if result.RowsAffected() == 0 {
return ErrTrustCenterReferenceNotFound{Identifier: t.ID.String()}
return ErrResourceNotFound
}
return nil

View File

@@ -43,24 +43,8 @@ type (
}
Users []*User
ErrUserNotFound struct {
Identifier string
}
ErrUserAlreadyExists struct {
message string
}
)
func (e ErrUserNotFound) Error() string {
return fmt.Sprintf("user not found: %q", e.Identifier)
}
func (e ErrUserAlreadyExists) Error() string {
return e.message
}
func (u User) CursorKey(orderBy UserOrderField) page.CursorKey {
switch orderBy {
case UserOrderFieldCreatedAt:
@@ -180,7 +164,7 @@ LIMIT 1;
user, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[User])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrUserNotFound{Identifier: email.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect user: %w", err)
@@ -224,7 +208,7 @@ LIMIT 1;
user, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[User])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrUserNotFound{Identifier: userID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect user: %w", err)
@@ -238,7 +222,6 @@ LIMIT 1;
func (u *User) Insert(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
q := `
INSERT INTO
@@ -272,9 +255,7 @@ VALUES (
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && strings.Contains(pgErr.ConstraintName, "email_address") {
return &ErrUserAlreadyExists{
message: fmt.Sprintf("user with email %s already exists", u.EmailAddress),
}
return ErrResourceAlreadyExists
}
}
@@ -284,71 +265,6 @@ VALUES (
return nil
}
func (u *User) UpdateEmailVerification(
ctx context.Context,
conn pg.Conn,
verified bool,
) error {
q := `
UPDATE
users
SET
email_address_verified = @email_address_verified,
updated_at = @updated_at
WHERE
id = @user_id
`
args := pgx.StrictNamedArgs{
"user_id": u.ID,
"email_address_verified": verified,
"updated_at": time.Now(),
}
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot update user email verification: %w", err)
}
u.EmailAddressVerified = verified
u.UpdatedAt = args["updated_at"].(time.Time)
return nil
}
func (u *User) UpdatePassword(
ctx context.Context,
conn pg.Conn,
hashedPassword []byte,
) error {
q := `
UPDATE
users
SET
hashed_password = @hashed_password,
updated_at = @updated_at
WHERE
id = @user_id
`
now := time.Now()
args := pgx.StrictNamedArgs{
"user_id": u.ID,
"hashed_password": hashedPassword,
"updated_at": now,
}
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot update user password: %w", err)
}
u.HashedPassword = hashedPassword
u.UpdatedAt = now
return nil
}
func (u *User) Update(ctx context.Context, conn pg.Conn) error {
q := `
UPDATE
@@ -357,6 +273,8 @@ SET
email_address = @email_address,
email_address_verified = @email_address_verified,
saml_subject = @saml_subject,
fullname = @fullname,
hashed_password = @hashed_password,
updated_at = @updated_at
WHERE
id = @user_id
@@ -368,13 +286,19 @@ WHERE
"email_address_verified": u.EmailAddressVerified,
"saml_subject": u.SAMLSubject,
"updated_at": u.UpdatedAt,
"fullname": u.FullName,
"hashed_password": u.HashedPassword,
}
_, err := conn.Exec(ctx, q, args)
result, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot update user: %w", err)
}
if result.RowsAffected() == 0 {
return ErrResourceNotFound
}
return nil
}
@@ -411,7 +335,7 @@ LIMIT 1;
user, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[User])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrUserNotFound{Identifier: samlSubject}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect user: %w", err)
@@ -422,10 +346,6 @@ LIMIT 1;
return nil
}
// LoadByEmailAndTenant, LoadByEmailGlobal, and IsTenantUser methods removed
// All users are now global (no tenant_id distinction)
// Use LoadByEmail() for all email-based lookups
func (u *User) CountMemberships(
ctx context.Context,
conn pg.Conn,

View File

@@ -23,27 +23,30 @@ import (
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
UserAPIKey struct {
ID gid.GID `db:"id"`
UserID gid.GID `db:"user_id"`
Name string `db:"name"`
ExpiresAt time.Time `db:"expires_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
ID gid.GID `db:"id"`
UserID gid.GID `db:"user_id"`
Name string `db:"name"`
ExpiresAt time.Time `db:"expires_at"`
ExpireReason *ExpireReason `db:"expire_reason"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
UserAPIKeys []*UserAPIKey
ErrUserAPIKeyNotFound struct {
Identifier string
}
)
func (e ErrUserAPIKeyNotFound) Error() string {
return fmt.Sprintf("user api key not found: %q", e.Identifier)
func (a *UserAPIKey) CursorKey(orderBy UserAPIKeyOrderField) page.CursorKey {
switch orderBy {
case UserAPIKeyOrderFieldCreatedAt:
return page.NewCursorKey(a.ID, a.CreatedAt)
}
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
}
func (a *UserAPIKey) LoadByID(
@@ -57,6 +60,7 @@ SELECT
user_id,
name,
expires_at,
expire_reason,
created_at,
updated_at
FROM
@@ -76,7 +80,7 @@ LIMIT 1;
apiKey, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[UserAPIKey])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrUserAPIKeyNotFound{Identifier: apiKeyID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect user api key: %w", err)
@@ -98,6 +102,7 @@ SELECT
user_id,
name,
expires_at,
expire_reason,
created_at,
updated_at
FROM
@@ -124,30 +129,53 @@ ORDER BY created_at DESC;
return nil
}
func (a *UserAPIKeys) CountByUserID(ctx context.Context, conn pg.Conn, userID gid.GID) (int, error) {
q := `
SELECT
COUNT(*)
FROM
auth_user_api_keys
WHERE
user_id = @user_id
ORDER BY created_at DESC;
`
args := pgx.StrictNamedArgs{"user_id": userID}
row := conn.QueryRow(ctx, q, args)
var count int
if err := row.Scan(&count); err != nil {
return 0, fmt.Errorf("cannot scan count: %w", err)
}
return count, nil
}
func (a *UserAPIKey) Insert(
ctx context.Context,
conn pg.Conn,
) error {
q := `
INSERT INTO
auth_user_api_keys (id, user_id, name, expires_at, created_at, updated_at)
auth_user_api_keys (id, user_id, name, expires_at, expire_reason, created_at, updated_at)
VALUES (
@api_key_id,
@user_id,
@name,
@expires_at,
@expire_reason,
@created_at,
@updated_at
)
`
args := pgx.StrictNamedArgs{
"api_key_id": a.ID,
"user_id": a.UserID,
"name": a.Name,
"expires_at": a.ExpiresAt,
"created_at": a.CreatedAt,
"updated_at": a.UpdatedAt,
"api_key_id": a.ID,
"user_id": a.UserID,
"name": a.Name,
"expires_at": a.ExpiresAt,
"expire_reason": a.ExpireReason,
"created_at": a.CreatedAt,
"updated_at": a.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)
@@ -168,16 +196,18 @@ UPDATE
SET
name = @name,
expires_at = @expires_at,
expire_reason = @expire_reason,
updated_at = @updated_at
WHERE
id = @api_key_id
`
args := pgx.StrictNamedArgs{
"api_key_id": a.ID,
"name": a.Name,
"expires_at": a.ExpiresAt,
"updated_at": a.UpdatedAt,
"api_key_id": a.ID,
"name": a.Name,
"expires_at": a.ExpiresAt,
"expire_reason": a.ExpireReason,
"updated_at": a.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)

View File

@@ -0,0 +1,40 @@
// 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 (
UserAPIKeyOrderField string
)
const (
UserAPIKeyOrderFieldCreatedAt UserAPIKeyOrderField = "CREATED_AT"
)
func (p UserAPIKeyOrderField) Column() string {
return string(p)
}
func (p UserAPIKeyOrderField) String() string {
return string(p)
}
func (p UserAPIKeyOrderField) MarshalText() ([]byte, error) {
return []byte(p.String()), nil
}
func (p *UserAPIKeyOrderField) UnmarshalText(text []byte) error {
*p = UserAPIKeyOrderField(text)
return nil
}

View File

@@ -63,24 +63,8 @@ type (
VendorSnapshotter interface {
InsertVendorSnapshots(ctx context.Context, conn pg.Conn, scope Scoper, organizationID, snapshotID gid.GID) error
}
ErrVendorNotFound struct {
Identifier string
}
ErrVendorAlreadyExists struct {
message string
}
)
func (e ErrVendorNotFound) Error() string {
return fmt.Sprintf("vendor not found: %q", e.Identifier)
}
func (e ErrVendorAlreadyExists) Error() string {
return e.message
}
func (v Vendor) CursorKey(orderBy VendorOrderField) page.CursorKey {
switch orderBy {
case VendorOrderFieldCreatedAt:
@@ -151,7 +135,7 @@ LIMIT 1;
vendor, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Vendor])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrVendorNotFound{Identifier: vendorID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect vendor: %w", err)

View File

@@ -21,11 +21,11 @@ import (
"maps"
"time"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
@@ -43,24 +43,8 @@ type (
}
VendorBusinessAssociateAgreements []*VendorBusinessAssociateAgreement
ErrVendorBusinessAssociateAgreementNotFound struct {
Identifier string
}
ErrVendorBusinessAssociateAgreementAlreadyExists struct {
message string
}
)
func (e ErrVendorBusinessAssociateAgreementNotFound) Error() string {
return fmt.Sprintf("vendor business associate agreement not found: %q", e.Identifier)
}
func (e ErrVendorBusinessAssociateAgreementAlreadyExists) Error() string {
return e.message
}
func (v VendorBusinessAssociateAgreement) CursorKey(orderBy VendorBusinessAssociateAgreementOrderField) page.CursorKey {
switch orderBy {
case VendorBusinessAssociateAgreementOrderFieldValidFrom:
@@ -263,9 +247,7 @@ ON CONFLICT (organization_id, vendor_id) DO UPDATE SET
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "vendor_business_associate_agreements_source_id_snapshot_id_key" {
return &ErrVendorBusinessAssociateAgreementAlreadyExists{
message: fmt.Sprintf("vendor business associate agreement with source_id %s and snapshot_id %s already exists", vbaa.SourceID, vbaa.SnapshotID),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot upsert vendor business associate agreement: %w", err)

View File

@@ -44,16 +44,8 @@ type (
}
VendorContacts []*VendorContact
ErrVendorContactNotFound struct {
Identifier string
}
)
func (e ErrVendorContactNotFound) Error() string {
return fmt.Sprintf("vendor contact not found: %s", e.Identifier)
}
func (vc VendorContact) CursorKey(orderBy VendorContactOrderField) page.CursorKey {
switch orderBy {
case VendorContactOrderFieldCreatedAt:
@@ -108,7 +100,7 @@ LIMIT 1;
vendorContact, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[VendorContact])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrVendorContactNotFound{Identifier: vendorContactID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect vendor contact: %w", err)

View File

@@ -21,11 +21,11 @@ import (
"maps"
"time"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
@@ -43,24 +43,8 @@ type (
}
VendorDataPrivacyAgreements []*VendorDataPrivacyAgreement
ErrVendorDataPrivacyAgreementNotFound struct {
Identifier string
}
ErrVendorDataPrivacyAgreementAlreadyExists struct {
message string
}
)
func (e ErrVendorDataPrivacyAgreementNotFound) Error() string {
return fmt.Sprintf("vendor data privacy agreement not found: %q", e.Identifier)
}
func (e ErrVendorDataPrivacyAgreementAlreadyExists) Error() string {
return e.message
}
func (v VendorDataPrivacyAgreement) CursorKey(orderBy VendorDataPrivacyAgreementOrderField) page.CursorKey {
switch orderBy {
case VendorDataPrivacyAgreementOrderFieldValidFrom:
@@ -263,9 +247,7 @@ ON CONFLICT (organization_id, vendor_id) DO UPDATE SET
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == "23505" && pgErr.ConstraintName == "vendor_data_privacy_agreements_source_id_snapshot_id_key" {
return &ErrVendorDataPrivacyAgreementAlreadyExists{
message: fmt.Sprintf("vendor data privacy agreement with source_id %s and snapshot_id %s already exists", vdpa.SourceID, vdpa.SnapshotID),
}
return ErrResourceAlreadyExists
}
}
return fmt.Errorf("cannot upsert vendor data privacy agreement: %w", err)

View File

@@ -41,16 +41,8 @@ type (
}
VendorServices []*VendorService
ErrVendorServiceNotFound struct {
Identifier string
}
)
func (e ErrVendorServiceNotFound) Error() string {
return fmt.Sprintf("vendor service not found: %s", e.Identifier)
}
func (vs VendorService) CursorKey(orderBy VendorServiceOrderField) page.CursorKey {
switch orderBy {
case VendorServiceOrderFieldCreatedAt:
@@ -101,7 +93,7 @@ LIMIT 1;
vendorService, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[VendorService])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrVendorServiceNotFound{Identifier: vendorServiceID.String()}
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect vendor service: %w", err)

View File

@@ -101,15 +101,19 @@ func (s *Service) PutFile(
Body: content,
ContentType: &file.MimeType,
Metadata: metadata,
})
},
)
if err != nil {
return 0, fmt.Errorf("cannot upload file to S3: %w", err)
}
headOutput, err := s.s3Client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: &file.BucketName,
Key: &file.FileKey,
})
headOutput, err := s.s3Client.HeadObject(
ctx,
&s3.HeadObjectInput{
Bucket: &file.BucketName,
Key: &file.FileKey,
},
)
if err != nil {
return 0, fmt.Errorf("cannot get object metadata: %w", err)
}

View File

@@ -0,0 +1,204 @@
// 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 iam
import (
"context"
"errors"
"fmt"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
type (
AccessManagementService struct {
*Service
}
)
func NewAccessManagementService(svc *Service) *AccessManagementService {
return &AccessManagementService{Service: svc}
}
// Authorize implements Model 2 authorization:
// - principalID is the actor (User now; later service accounts)
// - credentialID is an optional credential (UserAPIKey now)
// - intersection semantics: actor must be allowed AND credential (if present) must be allowed.
//
// Entity scope:
// - Global/self-owned entities (User/Session/UserAPIKey) are authorized via ownership checks only (no global admin).
// - Organization-scoped entities are authorized via membership lookups that derive organization_id from entityID.
func (s *AccessManagementService) Authorize(ctx context.Context, principalID gid.GID, credentialID *gid.GID, entityID gid.GID, action Action) error {
requiredRoles := GetPermissionsForAction(entityID.EntityType(), action)
if requiredRoles == nil {
entityModel, _ := coredata.EntityModel(entityID.EntityType())
return NewNoPermissionsDefinedError(entityModel, action)
}
switch principalID.EntityType() {
case coredata.UserEntityType:
// ok
default:
return NewUnsupportedPrincipalTypeError(principalID.EntityType())
}
return s.pg.WithConn(ctx, func(conn pg.Conn) error {
// Global/self-owned path
switch entityID.EntityType() {
case coredata.UserEntityType:
if entityID != principalID {
return NewInsufficientPermissionsError(principalID, entityID, action)
}
return nil
case coredata.SessionEntityType:
sess := &coredata.Session{}
if err := sess.LoadByID(ctx, conn, entityID); err != nil {
return NewInsufficientPermissionsError(principalID, entityID, action)
}
if sess.UserID != principalID {
return NewInsufficientPermissionsError(principalID, entityID, action)
}
return nil
case coredata.UserAPIKeyEntityType:
key := &coredata.UserAPIKey{}
if err := key.LoadByID(ctx, conn, entityID); err != nil {
return NewInsufficientPermissionsError(principalID, entityID, action)
}
if key.UserID != principalID {
return NewInsufficientPermissionsError(principalID, entityID, action)
}
return nil
}
// Organization-scoped path (derive org via joins)
scope := coredata.NewScope(entityID.TenantID())
actorRoleName, err := s.loadUserRoleForEntity(ctx, conn, scope, principalID, entityID)
if err != nil || !requiredRoleNamesContain(actorRoleName, requiredRoles) {
return NewInsufficientPermissionsError(principalID, entityID, action)
}
// Optional credential restriction (intersection)
if credentialID != nil {
switch credentialID.EntityType() {
case coredata.UserAPIKeyEntityType:
// Defensive check: credential must belong to actor
apiKey := &coredata.UserAPIKey{}
if err := apiKey.LoadByID(ctx, conn, *credentialID); err != nil {
return NewInsufficientPermissionsError(principalID, entityID, action)
}
if apiKey.UserID != principalID {
return NewInsufficientPermissionsError(principalID, entityID, action)
}
keyRoleName, err := s.loadAPIKeyRoleForEntity(ctx, conn, scope, *credentialID, entityID)
if err != nil || !requiredRoleNamesContain(keyRoleName, requiredRoles) {
return NewInsufficientPermissionsError(principalID, entityID, action)
}
default:
return NewUnsupportedPrincipalTypeError(credentialID.EntityType())
}
}
return nil
})
}
func (s *AccessManagementService) loadUserRoleForEntity(
ctx context.Context,
conn pg.Conn,
scope coredata.Scoper,
userID gid.GID,
entityID gid.GID,
) (Role, error) {
var m coredata.Membership
if err := m.LoadRoleByUserAndEntityID(ctx, conn, scope, userID, entityID); err != nil {
// Do not leak existence details
if errors.Is(err, coredata.ErrResourceNotFound) {
return "", err
}
return "", err
}
return Role(m.Role.String()), nil
}
func (s *AccessManagementService) loadAPIKeyRoleForEntity(
ctx context.Context,
conn pg.Conn,
scope coredata.Scoper,
apiKeyID gid.GID,
entityID gid.GID,
) (Role, error) {
var akm coredata.UserAPIKeyMembership
if err := akm.LoadRoleByAPIKeyAndEntityID(ctx, conn, scope, apiKeyID, entityID); err != nil {
return "", err
}
// Strict API key semantics: FULL only matches RoleFull explicitly.
switch akm.Role {
case coredata.APIRoleFull:
return RoleFull, nil
default:
return "", fmt.Errorf("unsupported api key role: %s", akm.Role)
}
}
// requiredRoleNamesContain is a temporary evaluator for the current in-code permissions registry
// (`Permissions` in `permissions.go`). In the future this becomes policy-document evaluation
// where the role name resolves to policy statements.
func requiredRoleNamesContain(roleName Role, required []Role) bool {
for _, r := range required {
if r == roleName {
return true
}
}
return false
}
// func (s AccountService) AllAccessibleTenants(ctx context.Context, identityID gid.GID) ([]gid.TenantID, error) {
// var tenants []gid.TenantID
// err := s.pg.WithConn(
// ctx,
// func(conn pg.Conn) error {
// memberships := coredata.Memberships{}
// orderBy := page.OrderBy[coredata.MembershipOrderField]{
// Field: coredata.MembershipOrderFieldCreatedAt,
// Direction: page.OrderDirectionDesc,
// }
// cursor := page.NewCursor(1000, nil, page.Head, orderBy)
// err := memberships.LoadByUserID(ctx, conn, coredata.NewNoScope(), identityID, cursor)
// if err != nil {
// return fmt.Errorf("cannot load memberships: %w", err)
// }
// for _, membership := range memberships {
// tenants = append(tenants, membership.ID.TenantID())
// }
// return nil
// },
// )
// if err != nil {
// return nil, err
// }
// return tenants, nil
// }

748
pkg/iam/account_service.go Normal file
View File

@@ -0,0 +1,748 @@
// 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 iam
import (
"context"
"fmt"
"time"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/packages/emails"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/mail"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/statelesstoken"
"go.probo.inc/probo/pkg/validator"
)
type (
AccountService struct {
*Service
}
UserAPIKeyTokenData struct {
Version int `json:"v"`
KeyID gid.GID `json:"kid"`
PrincipalID gid.GID `json:"pid"`
IssuedAt time.Time `json:"iat"`
}
EmailConfirmationData struct {
UserID gid.GID `json:"uid"`
Email mail.Addr `json:"email"`
}
)
const (
TokenTypeEmailConfirmation = "email_confirmation"
)
func NewAccountService(svc *Service) *AccountService {
return &AccountService{Service: svc}
}
type ChangeEmailRequest struct {
NewEmail mail.Addr
Password string
}
func (req ChangeEmailRequest) Validate() error {
v := validator.New()
v.Check(req.Password, "password", validator.NotEmpty(), validator.MaxLen(255)) // We cannot use PasswordValidator here because legacy password may not be aligned with the current password policy, therefore we at least enforce a maximum length to mitigate DDoS attacks.
return v.Error()
}
func (s AccountService) ChangeEmail(ctx context.Context, identityID gid.GID, req *ChangeEmailRequest) error {
if err := req.Validate(); err != nil {
return fmt.Errorf("invalid request: %w", err)
}
confirmationToken, err := statelesstoken.NewToken(
s.tokenSecret,
TokenTypeEmailConfirmation,
24*time.Hour,
EmailConfirmationData{UserID: identityID, Email: req.NewEmail},
)
if err != nil {
return fmt.Errorf("cannot generate confirmation token: %w", err)
}
base, err := baseurl.Parse(s.baseURL)
if err != nil {
return fmt.Errorf("cannot parse base URL: %w", err)
}
confirmationUrl, err := base.
WithPath("/auth/confirm-email").
WithQuery("token", confirmationToken).
String()
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
user := &coredata.User{}
err := user.LoadByID(ctx, tx, identityID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewUserNotFoundError(identityID)
}
return fmt.Errorf("cannot load user: %w", err)
}
isPasswordMatch, err := s.hp.ComparePasswordAndHash([]byte(req.Password), user.HashedPassword)
if err != nil {
return fmt.Errorf("cannot compare password: %w", err)
}
if !isPasswordMatch {
return NewInvalidPasswordError("invalid password")
}
user.EmailAddress = req.NewEmail
user.EmailAddressVerified = false
user.UpdatedAt = time.Now()
err = user.Update(ctx, tx)
if err != nil {
return fmt.Errorf("cannot update user: %w", err)
}
subject, textBody, htmlBody, err := emails.RenderConfirmEmail(
s.baseURL,
user.FullName,
confirmationUrl,
)
if err != nil {
return fmt.Errorf("cannot render confirmation email: %w", err)
}
confirmationEmail := coredata.NewEmail(
user.FullName,
user.EmailAddress,
subject,
textBody,
htmlBody,
)
err = confirmationEmail.Insert(ctx, tx)
if err != nil {
return fmt.Errorf("cannot insert confirmation email: %w", err)
}
return nil
},
)
}
func (s AccountService) VerifyEmail(ctx context.Context, token string) error {
payload, err := statelesstoken.ValidateToken[EmailConfirmationData](s.tokenSecret, TokenTypeEmailConfirmation, token)
if err != nil {
return NewInvalidTokenError()
}
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
user := &coredata.User{}
err := user.LoadByID(ctx, tx, payload.Data.UserID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewUserNotFoundError(payload.Data.UserID)
}
return fmt.Errorf("cannot load user: %w", err)
}
if user.EmailAddress != payload.Data.Email {
return NewEmailVerificationMismatchError()
}
if user.EmailAddressVerified {
return NewEmailAlreadyVerifiedError()
}
user.EmailAddressVerified = true
user.UpdatedAt = time.Now()
err = user.Update(ctx, tx)
if err != nil {
return fmt.Errorf("cannot update user: %w", err)
}
return nil
},
)
}
func (s *AccountService) AcceptInvitation(
ctx context.Context,
identityID gid.GID,
invitationID gid.GID,
) (*coredata.Membership, error) {
var (
now = time.Now()
membership = &coredata.Membership{}
)
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
user := coredata.User{}
invitation := coredata.Invitation{}
err := user.LoadByID(ctx, tx, identityID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewUserNotFoundError(identityID)
}
return fmt.Errorf("cannot load user: %w", err)
}
err = invitation.LoadByID(ctx, tx, coredata.NewNoScope(), invitationID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewInvitationNotFoundError(invitationID)
}
return fmt.Errorf("cannot load invitation: %w", err)
}
if invitation.Email != user.EmailAddress {
return NewInvitationNotFoundError(invitationID)
}
if invitation.AcceptedAt != nil {
return NewInvitationAlreadyAcceptedError(invitationID)
}
if invitation.ExpiresAt.Before(now) {
return NewInvitationExpiredError(invitationID)
}
tenantID := invitation.OrganizationID.TenantID()
scope := coredata.NewScope(invitation.OrganizationID.TenantID())
membership = &coredata.Membership{
ID: gid.New(tenantID, coredata.MembershipEntityType),
UserID: identityID,
OrganizationID: invitation.OrganizationID,
Role: invitation.Role,
CreatedAt: now,
UpdatedAt: now,
}
err = membership.Insert(ctx, tx, scope)
if err != nil {
return fmt.Errorf("cannot create membership: %w", err)
}
invitation.AcceptedAt = &now
err = invitation.Update(ctx, tx, scope)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewInvitationNotFoundError(invitationID)
}
return fmt.Errorf("cannot update invitation: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return membership, nil
}
func (s *AccountService) ListPendingInvitations(
ctx context.Context,
identityID gid.GID,
cursor *page.Cursor[coredata.InvitationOrderField],
) (*page.Page[*coredata.Invitation, coredata.InvitationOrderField], error) {
var invitations coredata.Invitations
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
identity := coredata.User{}
err := identity.LoadByID(ctx, conn, identityID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewUserNotFoundError(identityID)
}
return fmt.Errorf("cannot load identity: %w", err)
}
onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending})
err = invitations.LoadByIdentityID(ctx, conn, coredata.NewNoScope(), identity.EmailAddress, cursor, onlyPending)
if err != nil {
return fmt.Errorf("cannot load invitations: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(invitations, cursor), nil
}
func (s *AccountService) CountPendingInvitations(
ctx context.Context,
identityID gid.GID,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
identity := coredata.User{}
err := identity.LoadByID(ctx, conn, identityID)
if err != nil {
return fmt.Errorf("cannot load identity: %w", err)
}
invitations := coredata.Invitations{}
onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending})
count, err = invitations.CountByEmail(ctx, conn, identity.EmailAddress, onlyPending)
if err != nil {
return fmt.Errorf("cannot count pending invitations: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s *AccountService) ListMemberships(
ctx context.Context,
identityID gid.GID,
cursor *page.Cursor[coredata.MembershipOrderField],
) (*page.Page[*coredata.Membership, coredata.MembershipOrderField], error) {
var memberships coredata.Memberships
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
err := memberships.LoadByUserID(ctx, conn, coredata.NewNoScope(), identityID, cursor)
if err != nil {
return fmt.Errorf("cannot load memberships: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(memberships, cursor), nil
}
func (s *AccountService) CountMemberships(
ctx context.Context,
identityID gid.GID,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) (err error) {
memberships := coredata.Memberships{}
count, err = memberships.CountByUserID(ctx, conn, identityID)
if err != nil {
return fmt.Errorf("cannot count memberships: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s AccountService) ChangePassword(ctx context.Context, identityID gid.GID, req *ChangePasswordRequest) error {
if err := req.Validate(); err != nil {
return fmt.Errorf("invalid request: %w", err)
}
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
user := &coredata.User{}
err := user.LoadByID(ctx, tx, identityID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewUserNotFoundError(identityID)
}
return fmt.Errorf("cannot load user: %w", err)
}
isLegacyPasswordMatch, err := s.hp.ComparePasswordAndHash([]byte(req.CurrentPassword), user.HashedPassword)
if err != nil {
return fmt.Errorf("cannot compare legacy password: %w", err)
}
if !isLegacyPasswordMatch {
return NewInvalidPasswordError("invalid current password")
}
newPasswordHash, err := s.hp.HashPassword([]byte(req.NewPassword))
if err != nil {
return fmt.Errorf("cannot hash new password: %w", err)
}
user.HashedPassword = newPasswordHash
user.UpdatedAt = time.Now()
err = user.Update(ctx, tx)
if err != nil {
return fmt.Errorf("cannot update user: %w", err)
}
// TODO: email to notify user that their password has been changed
return nil
},
)
}
func (s AccountService) CountSessions(ctx context.Context, identityID gid.GID) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) (err error) {
sessions := coredata.Sessions{}
count, err = sessions.CountByUserID(ctx, conn, identityID)
if err != nil {
return fmt.Errorf("cannot count sessions: %w", err)
}
return nil
},
)
return count, err
}
func (s AccountService) ListSessions(
ctx context.Context,
identityID gid.GID,
cursor *page.Cursor[coredata.SessionOrderField],
) (*page.Page[*coredata.Session, coredata.SessionOrderField], error) {
var sessions coredata.Sessions
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
err := sessions.LoadByUserID(ctx, conn, identityID, cursor)
if err != nil {
return fmt.Errorf("cannot load sessions: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(sessions, cursor), nil
}
func (s AccountService) GetIdentity(ctx context.Context, identityID gid.GID) (*coredata.User, error) {
user := &coredata.User{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
err := user.LoadByID(ctx, conn, identityID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewUserNotFoundError(identityID)
}
return fmt.Errorf("cannot load user: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return user, nil
}
func (s AccountService) ListPersonalAPIKeys(
ctx context.Context,
identityID gid.GID,
cursor *page.Cursor[coredata.UserAPIKeyOrderField],
) (*page.Page[*coredata.UserAPIKey, coredata.UserAPIKeyOrderField], error) {
var personalAccessTokens coredata.UserAPIKeys
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
err := personalAccessTokens.LoadByUserID(ctx, conn, identityID)
if err != nil {
return fmt.Errorf("cannot load personal access tokens: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(personalAccessTokens, cursor), nil
}
func (s AccountService) CountPersonalAPIKeys(ctx context.Context, identityID gid.GID) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) (err error) {
personalAccessTokens := coredata.UserAPIKeys{}
count, err = personalAccessTokens.CountByUserID(ctx, conn, identityID)
if err != nil {
return fmt.Errorf("cannot count personal access tokens: %w", err)
}
return nil
},
)
return count, err
}
func (s AccountService) GetIdentityForMembership(ctx context.Context, membershipID gid.GID) (*coredata.User, error) {
var (
scope = coredata.NewScopeFromObjectID(membershipID)
identity = &coredata.User{}
)
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
membership := &coredata.Membership{}
err := membership.LoadByID(ctx, conn, scope, membershipID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewMembershipNotFoundError(membershipID)
}
return fmt.Errorf("cannot load membership: %w", err)
}
err = identity.LoadByID(ctx, conn, membership.UserID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewUserNotFoundError(membership.UserID)
}
return fmt.Errorf("cannot load identity: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return identity, nil
}
func (s *AccountService) CreatePersonalAPIKey(
ctx context.Context,
identityID gid.GID,
name string,
expiresAt time.Time,
) (*coredata.UserAPIKey, string, error) {
var (
userAPIKey *coredata.UserAPIKey
token string
)
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) (err error) {
now := time.Now()
userAPIKey = &coredata.UserAPIKey{
ID: gid.New(gid.NilTenant, coredata.UserAPIKeyEntityType),
UserID: identityID,
Name: name,
ExpiresAt: expiresAt,
CreatedAt: now,
UpdatedAt: now,
}
if err := userAPIKey.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert user api key: %w", err)
}
token, err = statelesstoken.NewDeterministicToken(
s.tokenSecret,
TokenTypeAPIKey,
userAPIKey.ExpiresAt,
userAPIKey.CreatedAt,
UserAPIKeyTokenData{
Version: 2,
KeyID: userAPIKey.ID,
PrincipalID: identityID,
IssuedAt: userAPIKey.CreatedAt,
},
)
if err != nil {
return fmt.Errorf("cannot generate user api key token: %w", err)
}
return nil
},
)
if err != nil {
return nil, "", err
}
return userAPIKey, token, nil
}
func (s *AccountService) DeletePersonalAPIKey(
ctx context.Context,
identityID gid.GID,
userAPIKeyID gid.GID,
) error {
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
userAPIKey := &coredata.UserAPIKey{}
err := userAPIKey.LoadByID(ctx, tx, userAPIKeyID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewUserAPIKeyNotFoundError(userAPIKeyID)
}
return fmt.Errorf("cannot load user api key: %w", err)
}
if userAPIKey.UserID != identityID {
return NewUserAPIKeyNotFoundError(userAPIKeyID)
}
err = userAPIKey.Delete(ctx, tx)
if err != nil {
return fmt.Errorf("cannot delete user api key: %w", err)
}
return nil
},
)
}
func (s AccountService) ListOrganizations(ctx context.Context, identityID gid.GID) ([]*coredata.Organization, error) {
var organizations coredata.Organizations
orderBy := page.OrderBy[coredata.OrganizationOrderField]{
Field: coredata.OrganizationOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
cursor := page.NewCursor(1000, nil, page.Head, orderBy)
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
err := organizations.LoadByUserID(ctx, conn, coredata.NewNoScope(), identityID, cursor)
if err != nil {
return fmt.Errorf("cannot load organizations: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return organizations, nil
}
// func (s AccountService) AllAccessibleTenants(ctx context.Context, identityID gid.GID) ([]gid.TenantID, error) {
// var tenants []gid.TenantID
// err := s.pg.WithConn(
// ctx,
// func(conn pg.Conn) error {
// memberships := coredata.Memberships{}
// orderBy := page.OrderBy[coredata.MembershipOrderField]{
// Field: coredata.MembershipOrderFieldCreatedAt,
// Direction: page.OrderDirectionDesc,
// }
// cursor := page.NewCursor(1000, nil, page.Head, orderBy)
// err := memberships.LoadByUserID(ctx, conn, coredata.NewNoScope(), identityID, cursor)
// if err != nil {
// return fmt.Errorf("cannot load memberships: %w", err)
// }
// for _, membership := range memberships {
// tenants = append(tenants, membership.ID.TenantID())
// }
// return nil
// },
// )
// if err != nil {
// return nil, err
// }
// return tenants, nil
// }

View File

@@ -0,0 +1,41 @@
// 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 iam
type (
Action string
)
const (
ActionIAMOrganizationCreate Action = "iam:organization:create"
ActionIAMOrganizationUpdate Action = "iam:organization:update"
ActionIAMOrganizationGet Action = "iam:organization:get"
ActionIAMOrganizationDelete Action = "iam:organization:delete"
ActionIAMOrganizationList Action = "iam:organization:list"
ActionIAMOrganizationInviteMember Action = "iam:organization:invite-member"
ActionIAMOrganizationRemoveMember Action = "iam:organization:remove-member"
ActionIAMOrganizationListMembers Action = "iam:organization:list-members"
ActionIAMOrganizationListInvitations Action = "iam:organization:list-invitations"
ActionIAMIdentityListMemberships Action = "iam:identity:list-memberships"
ActionIAMIdentityListInvitations Action = "iam:identity:list-invitations"
ActionIAMIdentityListSessions Action = "iam:identity:list-sessions"
ActionIAMSessionClose Action = "iam:identity:close-session"
ActionIAMSessionRevoke Action = "iam:identity:revoke-session"
ActionIAMSessionRevokeAll Action = "iam:identity:revoke-all-sessions"
ActionIAMInvitationAccept Action = "iam:identity:accept-invitation"
)

78
pkg/iam/api_key.go Normal file
View File

@@ -0,0 +1,78 @@
// 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 iam
import (
"context"
"fmt"
"time"
"go.gearno.de/kit/pg"
"go.gearno.de/x/ref"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
type (
APIKeyService struct {
*Service
}
)
func NewAPIKeyService(svc *Service) *APIKeyService {
return &APIKeyService{Service: svc}
}
func (s *APIKeyService) GetAPIKey(ctx context.Context, keyID gid.GID) (*coredata.UserAPIKey, error) {
var (
apiKey = &coredata.UserAPIKey{}
now = time.Now()
)
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
if err := apiKey.LoadByID(ctx, tx, keyID); err != nil {
if err == coredata.ErrResourceNotFound {
return NewUserAPIKeyNotFoundError(keyID)
}
}
if apiKey.ExpireReason != nil {
return NewUserAPIKeyExpiredError(keyID)
}
if now.After(apiKey.ExpiresAt) {
apiKey.ExpireReason = ref.Ref(coredata.ExpireReasonIdleTimeout)
apiKey.ExpiresAt = now
apiKey.UpdatedAt = now
if err := apiKey.Update(ctx, tx); err != nil {
return fmt.Errorf("cannot update user api key: %w", err)
}
return NewUserAPIKeyExpiredError(keyID)
}
return nil
},
)
if err != nil {
return nil, err
}
return apiKey, nil
}

488
pkg/iam/auth_service.go Normal file
View File

@@ -0,0 +1,488 @@
// 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 iam
import (
"context"
"fmt"
"time"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/packages/emails"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/mail"
"go.probo.inc/probo/pkg/statelesstoken"
"go.probo.inc/probo/pkg/validator"
)
type (
AuthService struct {
*Service
}
ResetPasswordRequest struct {
Token string
Password string
}
ChangePasswordRequest struct {
CurrentPassword string
NewPassword string
}
CreateIdentityFromInvitationRequest struct {
InvitationToken string
Password string
FullName string
}
CreateIdentityWithPasswordRequest struct {
Email mail.Addr
Password string
FullName string
}
PasswordResetData struct {
Email mail.Addr `json:"email"`
}
)
const (
TokenTypeOrganizationInvitation = "organization_invitation"
TokenTypePasswordReset = "password_reset"
)
func NewAuthService(svc *Service) *AuthService {
return &AuthService{Service: svc}
}
func (req CreateIdentityFromInvitationRequest) Validate() error {
v := validator.New()
v.Check(req.InvitationToken, "invitationToken", validator.NotEmpty())
v.Check(req.FullName, "fullName", validator.NotEmpty(), validator.MinLen(1), validator.MaxLen(255))
v.Check(req.Password, "password", PasswordValidator())
return v.Error()
}
func (req ResetPasswordRequest) Validate() error {
v := validator.New()
v.Check(req.Token, "token", validator.NotEmpty())
v.Check(req.Password, "password", PasswordValidator())
return v.Error()
}
func (req ChangePasswordRequest) Validate() error {
v := validator.New()
// We cannot use PasswordValidator here because legacy password may not be aligned with the current password
// policy, therefore we at least enforce a maximum length to mitigate DDoS attacks.
v.Check(req.CurrentPassword, "currentPassword", validator.NotEmpty(), validator.MaxLen(255))
v.Check(req.NewPassword, "newPassword", PasswordValidator())
return v.Error()
}
func (req CreateIdentityWithPasswordRequest) Validate() error {
v := validator.New()
v.Check(req.FullName, "fullName", validator.NotEmpty(), validator.MinLen(1), validator.MaxLen(255))
v.Check(req.Password, "password", PasswordValidator())
return v.Error()
}
func (s *AuthService) CreateIdentityFromInvitation(
ctx context.Context,
req *CreateIdentityFromInvitationRequest,
) (*coredata.User, *coredata.Session, error) {
if err := req.Validate(); err != nil {
return nil, nil, fmt.Errorf("invalid request: %w", err)
}
payload, err := statelesstoken.ValidateToken[InvitationTokenData](s.tokenSecret, TokenTypeOrganizationInvitation, req.InvitationToken)
if err != nil {
return nil, nil, NewInvalidTokenError()
}
var (
scope = coredata.NewScopeFromObjectID(payload.Data.InvitationID)
invitation = &coredata.Invitation{}
user = &coredata.User{}
session = &coredata.Session{}
now = time.Now()
)
hashedPassword, err := s.hp.HashPassword([]byte(req.Password))
if err != nil {
return nil, nil, fmt.Errorf("cannot hash password: %w", err)
}
err = s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
err := invitation.LoadByID(ctx, tx, scope, payload.Data.InvitationID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewInvitationNotFoundError(payload.Data.InvitationID)
}
return fmt.Errorf("cannot load invitation: %w", err)
}
if invitation.AcceptedAt != nil {
return NewInvitationAlreadyAcceptedError(payload.Data.InvitationID)
}
if invitation.ExpiresAt.Before(now) {
return NewInvitationExpiredError(payload.Data.InvitationID)
}
user = &coredata.User{
ID: gid.New(gid.NilTenant, coredata.UserEntityType),
EmailAddress: invitation.Email,
HashedPassword: hashedPassword,
EmailAddressVerified: true,
FullName: invitation.FullName,
CreatedAt: now,
UpdatedAt: now,
}
err = user.Insert(ctx, tx)
if err != nil {
if err == coredata.ErrResourceAlreadyExists {
return NewUserAlreadyExistsError(invitation.Email)
}
return fmt.Errorf("cannot insert user: %w", err)
}
session = coredata.NewRootSession(user.ID, s.sessionDuration)
err = session.Insert(ctx, tx)
if err != nil {
return fmt.Errorf("cannot insert session: %w", err)
}
return nil
},
)
if err != nil {
return nil, nil, err
}
return user, session, nil
}
func (s AuthService) ResetPassword(
ctx context.Context,
req *ResetPasswordRequest,
) error {
if err := req.Validate(); err != nil {
return fmt.Errorf("invalid request: %w", err)
}
payload, err := statelesstoken.ValidateToken[PasswordResetData](s.tokenSecret, TokenTypePasswordReset, req.Token)
if err != nil {
return NewInvalidTokenError()
}
hashedPassword, err := s.hp.HashPassword([]byte(req.Password))
if err != nil {
return fmt.Errorf("cannot hash password: %w", err)
}
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
user := &coredata.User{}
err := user.LoadByEmail(ctx, tx, payload.Data.Email)
if err != nil {
if err == coredata.ErrResourceNotFound {
return nil // Don't leak information about non-existent users
}
return fmt.Errorf("cannot load user: %w", err)
}
user.HashedPassword = hashedPassword
user.UpdatedAt = time.Now()
err = user.Update(ctx, tx)
if err != nil {
if err == coredata.ErrResourceNotFound {
return nil // Don't leak information about non-existent users
}
return fmt.Errorf("cannot update user: %w", err)
}
return nil
},
)
}
func (s AuthService) SendPasswordResetInstructionByEmail(
ctx context.Context,
email mail.Addr,
) error {
token, err := statelesstoken.NewToken(
s.tokenSecret,
TokenTypePasswordReset,
s.passwordResetTokenValidity,
PasswordResetData{Email: email},
)
if err != nil {
return fmt.Errorf("cannot generate password reset token: %w", err)
}
base, err := baseurl.Parse(s.baseURL)
if err != nil {
return fmt.Errorf("cannot parse base URL: %w", err)
}
resetPasswordUrl := base.
WithPath("/auth/reset-password").
WithQuery("token", token).
MustString()
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
user := &coredata.User{}
if err := user.LoadByEmail(ctx, tx, email); err != nil {
if err == coredata.ErrResourceNotFound {
return nil // Don't leak information about non-existent users
}
return fmt.Errorf("cannot load user: %w", err)
}
subject, textBody, htmlBody, err := emails.RenderPasswordReset(
s.baseURL,
user.FullName,
resetPasswordUrl,
)
if err != nil {
return fmt.Errorf("cannot render password reset email: %w", err)
}
passwordResetEmail := coredata.NewEmail(
user.FullName,
user.EmailAddress,
subject,
textBody,
htmlBody,
)
err = passwordResetEmail.Insert(ctx, tx)
if err != nil {
return fmt.Errorf("cannot insert email: %w", err)
}
return nil
},
)
}
func (s AuthService) CreateIdentityWithPassword(
ctx context.Context,
req *CreateIdentityWithPasswordRequest,
) (*coredata.User, *coredata.Session, error) {
if s.disableSignup { // TODO Rename this one to disableSignup
return nil, nil, NewErrSignupDisabled()
}
if err := req.Validate(); err != nil {
return nil, nil, fmt.Errorf("invalid request: %w", err)
}
hashedPassword, err := s.hp.HashPassword([]byte(req.Password))
if err != nil {
return nil, nil, fmt.Errorf("cannot hash password: %w", err)
}
var (
now = time.Now()
user = &coredata.User{
ID: gid.New(gid.NilTenant, coredata.UserEntityType),
EmailAddress: req.Email,
HashedPassword: hashedPassword,
EmailAddressVerified: false,
FullName: req.FullName,
CreatedAt: now,
UpdatedAt: now,
}
session = &coredata.Session{
ID: gid.New(gid.NilTenant, coredata.SessionEntityType),
UserID: user.ID,
Data: coredata.SessionData{
PasswordAuthenticated: true,
SAMLAuthenticatedOrgs: make(map[string]coredata.SAMLAuthInfo),
},
ExpiredAt: now.Add(24 * time.Hour * 7), // 7 days, TODO must to be hardcoded here
CreatedAt: now,
UpdatedAt: now,
}
)
confirmationToken, err := statelesstoken.NewToken(
s.tokenSecret,
TokenTypeEmailConfirmation,
24*time.Hour,
EmailConfirmationData{UserID: user.ID, Email: user.EmailAddress},
)
if err != nil {
return nil, nil, fmt.Errorf("cannot generate confirmation token: %w", err)
}
base, err := baseurl.Parse(s.baseURL)
if err != nil {
return nil, nil, fmt.Errorf("cannot parse base URL: %w", err)
}
confirmationUrl, err := base.
WithPath("/auth/confirm-email").
WithQuery("token", confirmationToken).
String()
if err != nil {
return nil, nil, fmt.Errorf("cannot build confirmation URL: %w", err)
}
subject, textBody, htmlBody, err := emails.RenderConfirmEmail(
s.baseURL,
user.FullName,
confirmationUrl,
)
if err != nil {
return nil, nil, fmt.Errorf("cannot render confirmation email: %w", err)
}
confirmationEmail := coredata.NewEmail(
user.FullName,
user.EmailAddress,
subject,
textBody,
htmlBody,
)
err = s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
err := user.Insert(ctx, tx)
if err != nil {
if err == coredata.ErrResourceAlreadyExists {
return NewUserAlreadyExistsError(user.EmailAddress)
}
return fmt.Errorf("cannot insert user: %w", err)
}
if err := confirmationEmail.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert email: %w", err)
}
if err := session.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert session: %w", err)
}
return nil
},
)
return user, session, err
}
func (s AuthService) OpenSessionWithoutPassword(ctx context.Context, userID gid.GID, organizationID gid.GID) (*coredata.Session, error) {
session := &coredata.Session{}
err := s.pg.WithTx(
ctx,
func(conn pg.Conn) (err error) {
session = coredata.NewRootSession(userID, s.sessionDuration)
err = session.Insert(ctx, conn)
if err != nil {
return fmt.Errorf("cannot insert session: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return session, nil
}
func (s AuthService) OpenSessionWithPassword(ctx context.Context, email mail.Addr, password string) (*coredata.User, *coredata.Session, error) {
v := validator.New()
v.Check(password, "password", PasswordValidator())
err := v.Error()
if err != nil {
return nil, nil, err
}
var (
user = &coredata.User{}
session = &coredata.Session{}
)
err = s.pg.WithTx(
ctx,
func(conn pg.Conn) error {
err := user.LoadByEmail(ctx, conn, email)
if err != nil {
// Do not leak information about non-existent users
if err != coredata.ErrResourceNotFound {
return fmt.Errorf("cannot load user by email: %w", err)
}
}
// Perform a password comparison even when the user does not exist to mitigate timing attacks
// and prevent revealing account existence.
if user.ID == gid.Nil {
s.hp.ComparePasswordAndHash([]byte(password+"qwertyuiop1234567890"), []byte("qwertyuiop1234567890"))
return NewInvalidCredentialsError("invalid email or password")
}
isPasswordMatch, err := s.hp.ComparePasswordAndHash([]byte(password), user.HashedPassword)
if err != nil {
return fmt.Errorf("cannot verify password: %w", err)
}
if !isPasswordMatch {
return NewInvalidCredentialsError("invalid email or password")
}
session = coredata.NewRootSession(user.ID, s.sessionDuration)
err = session.Insert(ctx, conn)
if err != nil {
return fmt.Errorf("cannot insert session: %w", err)
}
return nil
},
)
return user, session, err
}

289
pkg/iam/errors.go Normal file
View File

@@ -0,0 +1,289 @@
// 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 iam
import (
"fmt"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/mail"
)
type ErrInvalidToken struct{ message string }
func NewInvalidTokenError() error {
return &ErrInvalidToken{"invalid invitation token"}
}
func (e ErrInvalidToken) Error() string {
return e.message
}
type ErrInvitationAlreadyAccepted struct{ InvitationID gid.GID }
func NewInvitationAlreadyAcceptedError(invitationID gid.GID) error {
return &ErrInvitationAlreadyAccepted{InvitationID: invitationID}
}
func (e ErrInvitationAlreadyAccepted) Error() string {
return fmt.Sprintf("invitation %q already accepted", e.InvitationID)
}
type ErrInvitationNotFound struct{ InvitationID gid.GID }
func NewInvitationNotFoundError(invitationID gid.GID) error {
return &ErrInvitationNotFound{InvitationID: invitationID}
}
func (e ErrInvitationNotFound) Error() string {
return fmt.Sprintf("invitation %q not found", e.InvitationID)
}
type ErrInvitationExpired struct{ InvitationID gid.GID }
func NewInvitationExpiredError(invitationID gid.GID) error {
return &ErrInvitationExpired{InvitationID: invitationID}
}
func (e ErrInvitationExpired) Error() string {
return fmt.Sprintf("invitation %q expired", e.InvitationID)
}
type ErrUserAlreadyExists struct{ EmailAddress mail.Addr }
func NewUserAlreadyExistsError(emailAddress mail.Addr) error {
return &ErrUserAlreadyExists{EmailAddress: emailAddress}
}
func (e ErrUserAlreadyExists) Error() string {
return fmt.Sprintf("user %q already exists", e.EmailAddress.String())
}
type ErrEmailAlreadyVerified struct{ message string }
func NewEmailAlreadyVerifiedError() error {
return &ErrEmailAlreadyVerified{"email already verified"}
}
func (e ErrEmailAlreadyVerified) Error() string {
return e.message
}
type ErrUserNotFound struct{ UserID gid.GID }
func NewUserNotFoundError(userID gid.GID) error {
return &ErrUserNotFound{userID}
}
func (e ErrUserNotFound) Error() string {
return fmt.Sprintf("user %q not found", e.UserID)
}
type ErrInvalidPassword struct{ message string }
func NewInvalidPasswordError(message string) error {
return &ErrInvalidPassword{message}
}
func (e ErrInvalidPassword) Error() string {
return e.message
}
type ErrEmailVerificationMismatch struct{ message string }
func NewEmailVerificationMismatchError() error {
return &ErrEmailVerificationMismatch{"email verification mismatch"}
}
func (e ErrEmailVerificationMismatch) Error() string {
return e.message
}
type ErrMembershipNotFound struct {
MembershipID gid.GID
}
func NewMembershipNotFoundError(membershipID gid.GID) error {
return &ErrMembershipNotFound{MembershipID: membershipID}
}
func (e ErrMembershipNotFound) Error() string {
return fmt.Sprintf("membership %q not found", e.MembershipID)
}
type ErrOrganizationNotFound struct{ OrganizationID gid.GID }
func NewOrganizationNotFoundError(organizationID gid.GID) error {
return &ErrOrganizationNotFound{OrganizationID: organizationID}
}
func (e ErrOrganizationNotFound) Error() string {
return fmt.Sprintf("organization %q not found", e.OrganizationID)
}
type ErrInsufficientPermissions struct {
IdentityID gid.GID
EntityID gid.GID
Action Action
}
func NewInsufficientPermissionsError(identityID gid.GID, entityID gid.GID, action Action) error {
return &ErrInsufficientPermissions{IdentityID: identityID, EntityID: entityID, Action: action}
}
func (e ErrInsufficientPermissions) Error() string {
return fmt.Sprintf("identity %q does not have sufficient permissions to perform action %s on entity %q", e.IdentityID, e.Action, e.EntityID)
}
type ErrSessionNotFound struct{ SessionID gid.GID }
func NewSessionNotFoundError(sessionID gid.GID) error {
return &ErrSessionNotFound{SessionID: sessionID}
}
func (e ErrSessionNotFound) Error() string {
return fmt.Sprintf("session %q not found", e.SessionID)
}
type ErrSessionExpired struct{ SessionID gid.GID }
func NewSessionExpiredError(sessionID gid.GID) error {
return &ErrSessionExpired{SessionID: sessionID}
}
func (e ErrSessionExpired) Error() string {
return fmt.Sprintf("session %q expired", e.SessionID)
}
type ErrMembershipAlreadyExists struct {
UserID gid.GID
OrganizationID gid.GID
}
func NewMembershipAlreadyExistsError(userID gid.GID, organizationID gid.GID) error {
return &ErrMembershipAlreadyExists{UserID: userID, OrganizationID: organizationID}
}
func (e ErrMembershipAlreadyExists) Error() string {
return fmt.Sprintf("membership already exists for user %q in organization %q", e.UserID, e.OrganizationID)
}
type ErrSAMLConfigurationNotFound struct{ ConfigID gid.GID }
func NewSAMLConfigurationNotFoundError(configID gid.GID) error {
return &ErrSAMLConfigurationNotFound{ConfigID: configID}
}
func (e ErrSAMLConfigurationNotFound) Error() string {
return fmt.Sprintf("SAML configuration %q not found", e.ConfigID)
}
type ErrUserAPIKeyNotFound struct{ UserAPIKeyID gid.GID }
func NewUserAPIKeyNotFoundError(userAPIKeyID gid.GID) error {
return &ErrUserAPIKeyNotFound{UserAPIKeyID: userAPIKeyID}
}
func (e ErrUserAPIKeyNotFound) Error() string {
return fmt.Sprintf("user API key %q not found", e.UserAPIKeyID)
}
type ErrUserAPIKeyExpired struct{ UserAPIKeyID gid.GID }
func NewUserAPIKeyExpiredError(userAPIKeyID gid.GID) error {
return &ErrUserAPIKeyExpired{UserAPIKeyID: userAPIKeyID}
}
func (e ErrUserAPIKeyExpired) Error() string {
return fmt.Sprintf("user API key %q expired", e.UserAPIKeyID)
}
type ErrSAMLConfigurationDomainNotVerified struct{ ConfigID gid.GID }
func NewSAMLConfigurationDomainNotVerifiedError(configID gid.GID) error {
return &ErrSAMLConfigurationDomainNotVerified{ConfigID: configID}
}
func (e ErrSAMLConfigurationDomainNotVerified) Error() string {
return fmt.Sprintf("SAML configuration %q domain not verified", e.ConfigID)
}
type ErrUnsupportedPrincipalType struct{ EntityType uint16 }
func NewUnsupportedPrincipalTypeError(entityType uint16) error {
return &ErrUnsupportedPrincipalType{EntityType: entityType}
}
func (e ErrUnsupportedPrincipalType) Error() string {
return fmt.Sprintf("unsupported principal type: %d", e.EntityType)
}
type ErrNoPermissionsDefined struct {
EntityModel string
Action Action
}
func NewNoPermissionsDefinedError(entityModel string, action Action) error {
return &ErrNoPermissionsDefined{EntityModel: entityModel, Action: action}
}
func (e ErrNoPermissionsDefined) Error() string {
return fmt.Sprintf("no permissions defined for action %s on entity %s", e.Action, e.EntityModel)
}
type ErrSignupDisabled struct{}
func NewErrSignupDisabled() error {
return &ErrSignupDisabled{}
}
func (e ErrSignupDisabled) Error() string {
return "signup is disabled"
}
type ErrInvalidCredentials struct{ message string }
func NewInvalidCredentialsError(message string) error {
return &ErrInvalidCredentials{message}
}
func (e ErrInvalidCredentials) Error() string {
return e.message
}
type ErrInvitationNotPending struct{ InvitationID gid.GID }
func NewInvitationNotPendingError(invitationID gid.GID) error {
return &ErrInvitationNotPending{InvitationID: invitationID}
}
func (e ErrInvitationNotPending) Error() string {
return fmt.Sprintf("invitation %q is not pending", e.InvitationID)
}
// TenantAccessError is used by API recovery middleware to translate authorization/tenant failures
// into a consistent client-facing error response.
//
// NOTE: This is intentionally generic to avoid leaking resource existence.
type TenantAccessError struct {
Message string
}
func (e *TenantAccessError) Error() string {
if e == nil || e.Message == "" {
return "tenant access denied"
}
return e.Message
}

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package authz
package iam
import (
"slices"
@@ -22,8 +22,6 @@ import (
type (
Role string
Action string
)
const (

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
package saml
import (
"fmt"
@@ -23,7 +23,75 @@ import (
"go.probo.inc/probo/pkg/mail"
)
func ExtractAttributeValue(assertion *saml.Assertion, attributeName string) (string, error) {
func extractUserAttributes(assertion *saml.Assertion, config *coredata.SAMLConfiguration) (mail.Addr, string, *coredata.MembershipRole, error) {
var (
email mail.Addr
fullname string
role *coredata.MembershipRole
)
if len(assertion.AttributeStatements) == 0 {
if assertion.Subject != nil && assertion.Subject.NameID != nil {
email, err := mail.ParseAddr(assertion.Subject.NameID.Value)
if err != nil {
return mail.Nil, "", nil, fmt.Errorf("cannot parse email: %w", err)
}
fullname = email.String()
role = nil
return email, fullname, role, nil
}
return mail.Nil, "", nil, fmt.Errorf("no attribute statement and no NameID in assertion")
}
emailString, err := extractAttributeValue(assertion, config.AttributeEmail)
if err != nil {
if assertion.Subject != nil && assertion.Subject.NameID != nil {
emailString = assertion.Subject.NameID.Value
} else {
return mail.Nil, "", nil, fmt.Errorf("cannot extract email: %w", err)
}
}
email, err = mail.ParseAddr(emailString)
if err != nil {
return mail.Nil, "", nil, fmt.Errorf("cannot parse email: %w", err)
}
firstname, err := extractAttributeValue(assertion, config.AttributeFirstname)
if err != nil {
firstname = ""
}
lastname, err := extractAttributeValue(assertion, config.AttributeLastname)
if err != nil {
lastname = ""
}
if firstname != "" && lastname != "" {
fullname = strings.TrimSpace(firstname + " " + lastname)
} else if firstname != "" {
fullname = firstname
} else if lastname != "" {
fullname = lastname
} else {
fullname = email.String()
}
roleString, err := extractAttributeValue(assertion, config.AttributeRole)
if err != nil {
role = nil
}
if roleString != "" {
role = mapSAMLRoleToSystemRole(roleString)
}
return email, fullname, role, nil
}
func extractAttributeValue(assertion *saml.Assertion, attributeName string) (string, error) {
if len(assertion.AttributeStatements) == 0 {
return "", fmt.Errorf("no attribute statement in assertion")
}
@@ -34,6 +102,7 @@ func ExtractAttributeValue(assertion *saml.Assertion, attributeName string) (str
if len(attr.Values) == 0 {
return "", fmt.Errorf("attribute %q has no values", attributeName)
}
return attr.Values[0].Value, nil
}
}
@@ -42,7 +111,7 @@ func ExtractAttributeValue(assertion *saml.Assertion, attributeName string) (str
return "", fmt.Errorf("attribute %q not found in assertion", attributeName)
}
func ExtractEmailFromAssertion(assertion *saml.Assertion) (string, error) {
func extractEmailFromAssertion(assertion *saml.Assertion) (string, error) {
commonEmailAttributes := []string{
"email",
"Email",
@@ -53,7 +122,7 @@ func ExtractEmailFromAssertion(assertion *saml.Assertion) (string, error) {
}
for _, attrName := range commonEmailAttributes {
email, err := ExtractAttributeValue(assertion, attrName)
email, err := extractAttributeValue(assertion, attrName)
if err == nil && email != "" {
return email, nil
}
@@ -66,7 +135,21 @@ func ExtractEmailFromAssertion(assertion *saml.Assertion) (string, error) {
return "", fmt.Errorf("could not extract email from assertion")
}
func MapSAMLRoleToSystemRole(samlRole string) *coredata.MembershipRole {
func extractEmailDomain(email string) (string, error) {
parts := strings.Split(email, "@")
if len(parts) != 2 {
return "", fmt.Errorf("invalid email address: %s", email)
}
domain := strings.ToLower(strings.TrimSpace(parts[1]))
if domain == "" {
return "", fmt.Errorf("empty domain in email address: %s", email)
}
return domain, nil
}
func mapSAMLRoleToSystemRole(samlRole string) *coredata.MembershipRole {
if samlRole != "" && isValidRole(samlRole) {
role := coredata.MembershipRole(samlRole)
return &role
@@ -83,61 +166,3 @@ func isValidRole(role string) bool {
return false
}
}
func ExtractUserAttributes(
assertion *saml.Assertion,
attributeEmail, attributeFirstname, attributeLastname, attributeRole string,
) (email mail.Addr, fullname, role string, err error) {
if len(assertion.AttributeStatements) == 0 {
if assertion.Subject != nil && assertion.Subject.NameID != nil {
email, err = mail.ParseAddr(assertion.Subject.NameID.Value)
if err != nil {
return mail.Nil, "", "", fmt.Errorf("invalid nameID as email address: %w", err)
}
fullname = email.String()
role = ""
return email, fullname, role, nil
}
return mail.Nil, "", "", fmt.Errorf("no attribute statement and no NameID in assertion")
}
emailString, err := ExtractAttributeValue(assertion, attributeEmail)
if err != nil {
if assertion.Subject != nil && assertion.Subject.NameID != nil {
emailString = assertion.Subject.NameID.Value
} else {
return mail.Nil, "", "", fmt.Errorf("cannot extract email: %w", err)
}
}
email, err = mail.ParseAddr(emailString)
if err != nil {
return mail.Nil, "", "", fmt.Errorf("invalid attribute email: %w", err)
}
firstname, err := ExtractAttributeValue(assertion, attributeFirstname)
if err != nil {
firstname = ""
}
lastname, err := ExtractAttributeValue(assertion, attributeLastname)
if err != nil {
lastname = ""
}
if firstname != "" && lastname != "" {
fullname = strings.TrimSpace(firstname + " " + lastname)
} else if firstname != "" {
fullname = firstname
} else if lastname != "" {
fullname = lastname
} else {
fullname = email.String()
}
role, err = ExtractAttributeValue(assertion, attributeRole)
if err != nil {
role = ""
}
return email, fullname, role, nil
}

90
pkg/iam/saml/errors.go Normal file
View File

@@ -0,0 +1,90 @@
// 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 saml
import (
"fmt"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/mail"
)
type ErrSAMLConfigurationNotFound struct{ ConfigID gid.GID }
func NewSAMLConfigurationNotFoundError(configID gid.GID) error {
return &ErrSAMLConfigurationNotFound{ConfigID: configID}
}
func (e ErrSAMLConfigurationNotFound) Error() string {
return fmt.Sprintf("SAML configuration %q not found", e.ConfigID)
}
type ErrSAMLDisabled struct{}
func NewSAMLDisabledError() error {
return &ErrSAMLDisabled{}
}
func (e ErrSAMLDisabled) Error() string {
return "SAML is disabled for this organization"
}
type ErrInvalidAssertion struct {
AssertionID string
Err error
}
func NewInvalidAssertionError(assertionID string, err error) error {
return &ErrInvalidAssertion{AssertionID: assertionID, Err: err}
}
func (e ErrInvalidAssertion) Error() string {
return fmt.Sprintf("invalid assertion %q: %v", e.AssertionID, e.Err)
}
type ErrReplayAttackDetected struct {
AssertionID string
}
func NewReplayAttackDetectedError(assertionID string) error {
return &ErrReplayAttackDetected{AssertionID: assertionID}
}
func (e ErrReplayAttackDetected) Error() string {
return fmt.Sprintf("replay attack detected for assertion %q", e.AssertionID)
}
type ErrEmailDomainMismatch struct {
Email mail.Addr
ExpectedDomain string
}
func NewEmailDomainMismatchError(email mail.Addr, expectedDomain string) error {
return &ErrEmailDomainMismatch{Email: email, ExpectedDomain: expectedDomain}
}
func (e ErrEmailDomainMismatch) Error() string {
return fmt.Sprintf("email domain mismatch: assertion contains email %q but SAML config is for domain %q", e.Email, e.ExpectedDomain)
}
type ErrSAMLAutoSignupDisabled struct{ ConfigID gid.GID }
func NewSAMLAutoSignupDisabledError(configID gid.GID) error {
return &ErrSAMLAutoSignupDisabled{ConfigID: configID}
}
func (e ErrSAMLAutoSignupDisabled) Error() string {
return fmt.Sprintf("SAML auto-signup is disabled for configuration %q", e.ConfigID)
}

97
pkg/iam/saml/gc.go Normal file
View File

@@ -0,0 +1,97 @@
// 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 saml
import (
"context"
"fmt"
"time"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
)
const (
DefaultGarbageCollectionInterval = 1 * time.Hour
)
type (
GarbageCollector struct {
pg *pg.Client
interval time.Duration
logger *log.Logger
}
)
func NewGarbageCollector(
pg *pg.Client,
interval time.Duration,
logger *log.Logger,
) *GarbageCollector {
return &GarbageCollector{
pg: pg,
interval: interval,
logger: logger.Named("saml.garbage_collector").With(log.Duration("interval", interval)),
}
}
func (gc *GarbageCollector) Run(ctx context.Context) error {
gc.logger.InfoCtx(ctx, "saml garbage collector starting")
if err := gc.cleanup(ctx); err != nil {
gc.logger.ErrorCtx(ctx, "cannot run initial cleanup", log.Error(err))
}
for {
select {
case <-ctx.Done():
gc.logger.InfoCtx(ctx, "saml garbage collector shutting down")
return ctx.Err()
case <-time.After(gc.interval):
if err := gc.cleanup(ctx); err != nil {
gc.logger.ErrorCtx(ctx, "cannot run periodic cleanup", log.Error(err))
}
}
}
}
func (gc *GarbageCollector) cleanup(ctx context.Context) error {
now := time.Now()
return gc.pg.WithTx(
ctx,
func(tx pg.Conn) error {
assertionsDeleted, err := coredata.DeleteExpiredSAMLAssertions(ctx, tx, now)
if err != nil {
return fmt.Errorf("cannot delete expired saml assertions: %w", err)
}
requestsDeleted, err := coredata.DeleteExpiredSAMLRequests(ctx, tx, now)
if err != nil {
return fmt.Errorf("cannot delete expired saml requests: %w", err)
}
gc.logger.InfoCtx(
ctx,
"saml garbage collector cleaned up expired assertions and requests",
log.Int64("assertions", assertionsDeleted),
log.Int64("requests", requestsDeleted),
)
return nil
},
)
}

View File

@@ -0,0 +1,87 @@
// 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 saml
import (
"crypto/x509"
"encoding/base64"
"encoding/xml"
"fmt"
"github.com/crewjam/saml"
)
func ParseIdpMetadata(metadataXML []byte) (string, string, *x509.Certificate, error) {
var entityDescriptor saml.EntityDescriptor
err := xml.Unmarshal(metadataXML, &entityDescriptor)
if err != nil {
return "", "", nil, fmt.Errorf("cannot parse metadata XML: %w", err)
}
if len(entityDescriptor.IDPSSODescriptors) == 0 {
return "", "", nil, fmt.Errorf("no IDPSSODescriptor found in metadata")
}
idpDescriptor := entityDescriptor.IDPSSODescriptors[0]
ssoURL, err := getSsoURLFromMetadata(idpDescriptor)
if err != nil {
return "", "", nil, fmt.Errorf("cannot get SSO URL from metadata: %w", err)
}
cert, err := getCertificateFromMetadata(idpDescriptor)
if err != nil {
return "", "", nil, fmt.Errorf("cannot get certificate from metadata: %w", err)
}
return entityDescriptor.EntityID, ssoURL, cert, nil
}
func getSsoURLFromMetadata(idpDescriptor saml.IDPSSODescriptor) (string, error) {
for _, sso := range idpDescriptor.SingleSignOnServices {
if sso.Binding == saml.HTTPPostBinding || sso.Binding == saml.HTTPRedirectBinding {
return sso.Location, nil
}
}
if len(idpDescriptor.SingleSignOnServices) > 0 {
return idpDescriptor.SingleSignOnServices[0].Location, nil
}
return "", fmt.Errorf("no SingleSignOnService found in metadata")
}
func getCertificateFromMetadata(idpDescriptor saml.IDPSSODescriptor) (*x509.Certificate, error) {
for _, keyDescriptor := range idpDescriptor.KeyDescriptors {
if keyDescriptor.Use == "signing" || keyDescriptor.Use == "" {
if len(keyDescriptor.KeyInfo.X509Data.X509Certificates) > 0 {
certData := keyDescriptor.KeyInfo.X509Data.X509Certificates[0].Data
certDER, err := base64.StdEncoding.DecodeString(certData)
if err != nil {
return nil, fmt.Errorf("cannot decode certificate: %w", err)
}
cert, err := x509.ParseCertificate(certDER)
if err != nil {
return nil, fmt.Errorf("cannot parse certificate: %w", err)
}
return cert, nil
}
}
}
return nil, fmt.Errorf("no signing certificate found in metadata")
}

402
pkg/iam/saml/service.go Normal file
View File

@@ -0,0 +1,402 @@
// 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 saml
import (
"context"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/xml"
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/crewjam/saml"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/crypto/cipher"
"go.probo.inc/probo/pkg/gid"
)
type (
Service struct {
pg *pg.Client
encryptionKey cipher.EncryptionKey
baseURL string
certificate *x509.Certificate
privateKey *rsa.PrivateKey
logger *log.Logger
}
UserInfo struct {
Email string
FullName string
Role *coredata.MembershipRole
SAMLSubject string
OrganizationID gid.GID
SAMLConfigID gid.GID
}
)
func NewService(
pg *pg.Client,
encryptionKey cipher.EncryptionKey,
baseURL string,
certificate *x509.Certificate,
privateKey *rsa.PrivateKey,
logger *log.Logger,
) (*Service, error) {
return &Service{
pg: pg,
encryptionKey: encryptionKey,
baseURL: baseURL,
certificate: certificate,
privateKey: privateKey,
logger: logger,
}, nil
}
func (s *Service) Run(ctx context.Context) error {
gc := NewGarbageCollector(s.pg, DefaultGarbageCollectionInterval, s.logger)
gcCtx, stopGC := context.WithCancel(ctx)
defer stopGC()
errCh := make(chan error, 1)
go func() {
errCh <- gc.Run(gcCtx)
}()
select {
case <-ctx.Done():
stopGC()
<-errCh
return ctx.Err()
case err := <-errCh:
if err != nil {
s.logger.ErrorCtx(ctx, "saml garbage collector failed", log.Error(err))
return err
}
return nil
}
}
func (s *Service) GenerateSpMetadata() ([]byte, error) {
sp := s.baseServiceProvider()
return xml.MarshalIndent(sp.Metadata(), "", " ")
}
func (s *Service) InitiateLogin(
ctx context.Context,
configID gid.GID,
) (*url.URL, error) {
var (
now = time.Now()
requestExpiry = now.Add(10 * time.Minute)
redirect *url.URL
)
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
config := &coredata.SAMLConfiguration{}
err := config.LoadByID(ctx, tx, coredata.NewNoScope(), configID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewSAMLConfigurationNotFoundError(configID)
}
return fmt.Errorf("cannot load SAML configuration: %w", err)
}
if config.EnforcementPolicy == coredata.SAMLEnforcementPolicyOff {
return NewSAMLDisabledError()
}
sp, err := s.serviceProvider(ctx, config)
if err != nil {
return fmt.Errorf("cannot build service provider: %w", err)
}
req, err := sp.MakeAuthenticationRequest(config.IdPSsoURL, saml.HTTPRedirectBinding, saml.HTTPPostBinding)
if err != nil {
return fmt.Errorf("cannot create authentication request: %w", err)
}
samlRequest := coredata.SAMLRequest{
ID: req.ID,
OrganizationID: config.OrganizationID,
CreatedAt: now,
ExpiresAt: requestExpiry,
}
if err := samlRequest.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert SAML request: %w", err)
}
redirect, err = req.Redirect(config.ID.String(), sp)
if err != nil {
return fmt.Errorf("cannot generate redirect URL: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return redirect, nil
}
func (s *Service) HandleAssertion(
ctx context.Context,
samlResponse string,
configID gid.GID,
) (*coredata.User, *coredata.Membership, error) {
var (
now = time.Now()
user = &coredata.User{}
membership = &coredata.Membership{}
)
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
config := &coredata.SAMLConfiguration{}
err := config.LoadByID(ctx, tx, coredata.NewNoScope(), configID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewSAMLConfigurationNotFoundError(configID)
}
return fmt.Errorf("cannot load SAML configuration: %w", err)
}
if config.EnforcementPolicy == coredata.SAMLEnforcementPolicyOff {
return NewSAMLDisabledError()
}
sp, err := s.serviceProvider(ctx, config)
if err != nil {
return fmt.Errorf("cannot create service provider: %w", err)
}
possibleRequestIDs, err := coredata.LoadValidRequestIDsForOrganization(ctx, tx, config.OrganizationID, now)
if err != nil {
return fmt.Errorf("cannot load valid request IDs: %w", err)
}
decodedResponse, err := base64.StdEncoding.DecodeString(samlResponse)
if err != nil {
return fmt.Errorf("cannot decode SAML response: %w", err)
}
assertion, err := sp.ParseXMLResponse(decodedResponse, possibleRequestIDs, sp.AcsURL)
if err != nil {
return fmt.Errorf("cannot parse SAML response: %w", err)
}
err = s.validateAssertion(assertion, config, now)
if err != nil {
return NewInvalidAssertionError(assertion.ID, err)
}
expiresAt := now.Add(24 * time.Hour)
if assertion.Conditions.NotOnOrAfter.IsZero() {
expiresAt = assertion.Conditions.NotOnOrAfter
}
samlAssertion := coredata.SAMLAssertion{
ID: assertion.ID,
OrganizationID: config.OrganizationID,
UsedAt: now,
ExpiresAt: expiresAt,
}
err = samlAssertion.Insert(ctx, tx)
if err != nil {
if err == coredata.ErrResourceAlreadyExists {
return NewReplayAttackDetectedError(samlAssertion.ID)
}
return fmt.Errorf("cannot insert SAML assertion: %w", err)
}
email, fullname, role, err := extractUserAttributes(assertion, config)
if err != nil {
return fmt.Errorf("cannot extract user attributes: %w", err)
}
if !strings.EqualFold(email.Domain(), config.EmailDomain) {
return NewEmailDomainMismatchError(email, config.EmailDomain)
}
err = user.LoadByEmail(ctx, tx, email)
if err == coredata.ErrResourceNotFound && !config.AutoSignupEnabled {
return NewSAMLAutoSignupDisabledError(config.ID)
} else if err == coredata.ErrResourceNotFound && config.AutoSignupEnabled {
*user = coredata.User{
ID: gid.New(gid.NilTenant, coredata.UserEntityType),
EmailAddress: email,
HashedPassword: nil,
EmailAddressVerified: true,
FullName: fullname,
CreatedAt: now,
UpdatedAt: now,
}
err := user.Insert(ctx, tx)
if err != nil {
return fmt.Errorf("cannot insert user: %w", err)
}
} else if err != nil {
return fmt.Errorf("cannot load user: %w", err)
} else {
user.SAMLSubject = &assertion.Subject.NameID.Value
user.FullName = fullname
user.EmailAddress = email
user.EmailAddressVerified = true
user.UpdatedAt = now
err = user.Update(ctx, tx)
if err != nil {
return fmt.Errorf("cannot update user: %w", err)
}
}
err = membership.LoadByUserAndOrg(ctx, tx, coredata.NewNoScope(), user.ID, config.OrganizationID)
if err != nil && err != coredata.ErrResourceNotFound {
return fmt.Errorf("cannot load membership: %w", err)
}
isMember := membership.ID != gid.Nil
if !isMember {
membership = &coredata.Membership{
ID: gid.New(config.ID.TenantID(), coredata.MembershipEntityType),
UserID: user.ID,
OrganizationID: config.OrganizationID,
Role: coredata.MembershipRoleViewer,
CreatedAt: now,
UpdatedAt: now,
}
err = membership.Insert(ctx, tx, coredata.NewNoScope())
if err != nil {
return fmt.Errorf("cannot insert membership: %w", err)
}
}
if role != nil {
membership.Role = *role
membership.UpdatedAt = now
err = membership.Update(ctx, tx, coredata.NewNoScope())
if err != nil {
return fmt.Errorf("cannot update membership: %w", err)
}
}
return nil
},
)
if err != nil {
return nil, nil, err
}
return user, membership, nil
}
func (s *Service) validateAssertion(assertion *saml.Assertion, config *coredata.SAMLConfiguration, now time.Time) error {
const clockSkewTolerance = 5 * time.Minute
if assertion.ID == "" {
return errors.New("assertion ID is required")
}
if assertion.Subject == nil || assertion.Subject.NameID == nil {
return fmt.Errorf("subject or NameID missing")
}
if assertion.Issuer.Value != config.IdPEntityID {
return fmt.Errorf("assertion issuer %q does not match expected issuer %q",
assertion.Issuer.Value, config.IdPEntityID)
}
if assertion.Conditions == nil {
return errors.New("assertion conditions are required")
}
if assertion.Conditions.NotOnOrAfter.IsZero() {
return errors.New("assertion NotOnOrAfter condition is required")
}
if !assertion.Conditions.NotBefore.IsZero() {
if now.Add(clockSkewTolerance).Before(assertion.Conditions.NotBefore) {
return fmt.Errorf("assertion not yet valid (NotBefore: %v, now: %v)",
assertion.Conditions.NotBefore, now)
}
}
if now.Add(-clockSkewTolerance).After(assertion.Conditions.NotOnOrAfter) {
return fmt.Errorf("assertion expired (NotOnOrAfter: %v, now: %v)",
assertion.Conditions.NotOnOrAfter, now)
}
if len(assertion.Conditions.AudienceRestrictions) == 0 {
return errors.New("assertion audience restriction is required")
}
expectedAudience := baseurl.MustParse(s.baseURL).WithPath("/api/connect/v1/saml/2.0/metadata").MustString()
audienceValid := false
for _, restriction := range assertion.Conditions.AudienceRestrictions {
if restriction.Audience.Value == expectedAudience {
audienceValid = true
break
}
}
if !audienceValid {
return fmt.Errorf("assertion audience %q does not match expected %q",
assertion.Conditions.AudienceRestrictions, expectedAudience)
}
return nil
}
func (s *Service) baseServiceProvider() *saml.ServiceProvider {
baseURL := baseurl.MustParse(s.baseURL)
metadataURL := baseURL.WithPath("/api/connect/v1/saml/2.0/metadata").URL()
acsURL := baseURL.WithPath("/api/connect/v1/saml/2.0/consume").URL()
return &saml.ServiceProvider{
EntityID: metadataURL.String(),
Key: s.privateKey,
Certificate: s.certificate,
MetadataURL: metadataURL,
AcsURL: acsURL,
SloURL: acsURL,
AllowIDPInitiated: true,
}
}

68
pkg/iam/saml/sp.go Normal file
View File

@@ -0,0 +1,68 @@
// 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 saml
import (
"context"
"encoding/base64"
"fmt"
"github.com/crewjam/saml"
"go.probo.inc/probo/pkg/coredata"
)
func (s *Service) serviceProvider(
ctx context.Context,
config *coredata.SAMLConfiguration,
) (*saml.ServiceProvider, error) {
cert, err := config.GetIdPCertificate()
if err != nil {
return nil, fmt.Errorf("cannot parse IdP certificate: %w", err)
}
sp := s.baseServiceProvider()
sp.IDPMetadata = &saml.EntityDescriptor{
EntityID: config.IdPEntityID,
IDPSSODescriptors: []saml.IDPSSODescriptor{
{
SSODescriptor: saml.SSODescriptor{
RoleDescriptor: saml.RoleDescriptor{
ProtocolSupportEnumeration: "urn:oasis:names:tc:SAML:2.0:protocol",
KeyDescriptors: []saml.KeyDescriptor{
{
Use: "signing",
KeyInfo: saml.KeyInfo{
X509Data: saml.X509Data{
X509Certificates: []saml.X509Certificate{
{Data: base64.StdEncoding.EncodeToString(cert.Raw)},
},
},
},
},
},
},
},
SingleSignOnServices: []saml.Endpoint{
{
Binding: saml.HTTPRedirectBinding,
Location: config.IdPSsoURL,
},
},
},
},
}
return sp, nil
}

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
package iam
import (
"crypto/x509"

194
pkg/iam/service.go Normal file
View File

@@ -0,0 +1,194 @@
package iam
import (
"context"
"crypto/rsa"
"crypto/x509"
"fmt"
"time"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"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"
)
type (
Service struct {
pg *pg.Client
fm *filemanager.Service
hp *passwdhash.Profile
encryptionKey cipher.EncryptionKey
baseURL string
tokenSecret string
disableSignup bool
invitationTokenValidity time.Duration
passwordResetTokenValidity time.Duration
sessionDuration time.Duration
bucket string
certificate *x509.Certificate
privateKey *rsa.PrivateKey
logger *log.Logger
AccountService *AccountService
OrganizationService *OrganizationService
SessionService *SessionService
AuthService *AuthService
SAMLService *saml.Service
APIKeyService *APIKeyService
AccessManagementService *AccessManagementService
}
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
}
)
func NewService(
ctx context.Context,
pgClient *pg.Client,
fm *filemanager.Service,
hp *passwdhash.Profile,
cfg Config,
) (*Service, error) {
if cfg.Bucket == "" {
return nil, fmt.Errorf("bucket is required")
}
if cfg.TokenSecret == "" {
return nil, fmt.Errorf("token secret is required")
}
if cfg.BaseURL == "" {
return nil, fmt.Errorf("base URL is required")
}
if len(cfg.EncryptionKey) == 0 {
return nil, fmt.Errorf("encryption key is required")
}
svc := &Service{
pg: pgClient,
fm: fm,
hp: hp,
baseURL: cfg.BaseURL,
tokenSecret: cfg.TokenSecret,
disableSignup: cfg.DisableSignup,
invitationTokenValidity: cfg.InvitationTokenValidity,
passwordResetTokenValidity: cfg.PasswordResetTokenValidity,
sessionDuration: cfg.SessionDuration,
bucket: cfg.Bucket,
certificate: cfg.Certificate,
privateKey: cfg.PrivateKey,
logger: cfg.Logger,
}
svc.AccountService = NewAccountService(svc)
svc.OrganizationService = NewOrganizationService(svc)
svc.SessionService = NewSessionService(svc)
svc.AuthService = NewAuthService(svc)
svc.APIKeyService = NewAPIKeyService(svc)
svc.AccessManagementService = NewAccessManagementService(svc)
samlService, err := saml.NewService(svc.pg, svc.encryptionKey, svc.baseURL, svc.certificate, svc.privateKey, cfg.Logger)
if err != nil {
return nil, fmt.Errorf("cannot create SAML service: %w", err)
}
svc.SAMLService = samlService
return svc, nil
}
func (s *Service) GetMembership(ctx context.Context, membershipID gid.GID) (*coredata.Membership, error) {
var (
scope = coredata.NewScopeFromObjectID(membershipID)
membership = &coredata.Membership{}
)
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
err := membership.LoadByID(ctx, conn, scope, membershipID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewMembershipNotFoundError(membershipID)
}
return fmt.Errorf("cannot load membership: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return membership, nil
}
func (s *Service) GetInvitation(ctx context.Context, invitationID gid.GID) (*coredata.Invitation, error) {
var (
scope = coredata.NewScopeFromObjectID(invitationID)
invitation = &coredata.Invitation{}
)
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
err := invitation.LoadByID(ctx, conn, scope, invitationID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewInvitationNotFoundError(invitationID)
}
return fmt.Errorf("cannot load invitation: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return invitation, nil
}
func (s *Service) GetSession(ctx context.Context, sessionID gid.GID) (*coredata.Session, error) {
session := &coredata.Session{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
err := session.LoadByID(ctx, conn, sessionID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewSessionNotFoundError(sessionID)
}
return fmt.Errorf("cannot load session: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return session, nil
}

266
pkg/iam/session_service.go Normal file
View File

@@ -0,0 +1,266 @@
// 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 iam
import (
"context"
"fmt"
"net"
"time"
"go.gearno.de/kit/pg"
"go.gearno.de/x/ref"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/validator"
)
type (
SessionService struct {
*Service
}
)
func NewSessionService(svc *Service) *SessionService {
return &SessionService{Service: svc}
}
type (
RevokeAllSessionsRequest struct {
CurrentSessionID gid.GID
}
)
func (req RevokeAllSessionsRequest) Validate() error {
v := validator.New()
v.Check(req.CurrentSessionID, "current_session_id", validator.GID(coredata.SessionEntityType))
return v.Error()
}
func (s SessionService) GetSession(ctx context.Context, sessionID gid.GID) (*coredata.Session, error) {
var (
session = &coredata.Session{}
now = time.Now()
)
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
if err := session.LoadByID(ctx, tx, sessionID); err != nil {
if err == coredata.ErrResourceNotFound {
return NewSessionNotFoundError(sessionID)
}
}
if session.ExpireReason != nil {
return NewSessionExpiredError(sessionID)
}
if now.After(session.ExpiredAt) {
session.ExpireReason = ref.Ref(coredata.ExpireReasonIdleTimeout)
session.ExpiredAt = now
session.UpdatedAt = now
if err := session.Update(ctx, tx); err != nil {
return fmt.Errorf("cannot update session: %w", err)
}
return NewSessionExpiredError(sessionID)
}
return nil
},
)
if err != nil {
return nil, err
}
return session, nil
}
func (s SessionService) CloseSession(ctx context.Context, sessionID gid.GID) error {
return s.pg.WithTx(
ctx,
func(conn pg.Conn) error {
session := &coredata.Session{}
if err := session.LoadByID(ctx, conn, sessionID); err != nil {
if err == coredata.ErrResourceNotFound {
return NewSessionNotFoundError(sessionID)
}
return fmt.Errorf("cannot load session: %w", err)
}
if session.ExpireReason != nil {
return NewSessionExpiredError(sessionID)
}
session.ExpireReason = ref.Ref(coredata.ExpireReasonClosed)
session.ExpiredAt = time.Now()
session.UpdatedAt = time.Now()
if err := session.Update(ctx, conn); err != nil {
if err == coredata.ErrResourceNotFound {
return NewSessionNotFoundError(sessionID)
}
return fmt.Errorf("cannot update session: %w", err)
}
return nil
},
)
}
func (s SessionService) RevokeSession(ctx context.Context, identityID gid.GID, sessionID gid.GID) error {
now := time.Now()
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
user := &coredata.User{}
err := user.LoadByID(ctx, tx, identityID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewUserNotFoundError(identityID)
}
return fmt.Errorf("cannot load user: %w", err)
}
session := &coredata.Session{}
err = session.LoadByID(ctx, tx, sessionID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewSessionNotFoundError(sessionID)
}
return fmt.Errorf("cannot load session: %w", err)
}
// TODO: move to dedicated query instead of LoadByID
if session.UserID != identityID {
return NewSessionNotFoundError(sessionID)
}
if session.ExpireReason != nil {
return NewSessionExpiredError(sessionID)
}
session.ExpireReason = ref.Ref(coredata.ExpireReasonRevoked)
session.ExpiredAt = now
session.UpdatedAt = now
if err := session.Update(ctx, tx); err != nil {
if err == coredata.ErrResourceNotFound {
return NewSessionNotFoundError(sessionID)
}
return fmt.Errorf("cannot update session: %w", err)
}
return nil
},
)
}
func (s SessionService) RevokeAllSessions(ctx context.Context, currentSessionID gid.GID) (int64, error) {
var count int64
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
session := coredata.Session{}
err := session.LoadByID(ctx, tx, currentSessionID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewSessionNotFoundError(currentSessionID)
}
return fmt.Errorf("cannot load session: %w", err)
}
sessions := coredata.Sessions{}
count, err = sessions.ExpireAllForUserExceptOneSession(ctx, tx, session.UserID, session.ID)
if err != nil {
return fmt.Errorf("cannot expire all sessions: %w", err)
}
return nil
},
)
return count, err
}
func (s SessionService) UpdateSessionInfo(ctx context.Context, sessionID gid.GID, userAgent string, ipAddress net.IP) error {
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
session := &coredata.Session{}
err := session.LoadByID(ctx, tx, sessionID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewSessionNotFoundError(sessionID)
}
return fmt.Errorf("cannot load session: %w", err)
}
session.UserAgent = userAgent
session.IPAddress = ipAddress
session.UpdatedAt = time.Now()
if err := session.Update(ctx, tx); err != nil {
if err == coredata.ErrResourceNotFound {
return NewSessionNotFoundError(sessionID)
}
return fmt.Errorf("cannot update session: %w", err)
}
return nil
},
)
}
func (s SessionService) UpdateSessionData(ctx context.Context, sessionID gid.GID, data coredata.SessionData) error {
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
session := &coredata.Session{}
err := session.LoadByID(ctx, tx, sessionID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewSessionNotFoundError(sessionID)
}
return fmt.Errorf("cannot load session: %w", err)
}
session.Data = data
session.UpdatedAt = time.Now()
if err := session.Update(ctx, tx); err != nil {
if err == coredata.ErrResourceNotFound {
return NewSessionNotFoundError(sessionID)
}
return fmt.Errorf("cannot update session: %w", err)
}
return nil
},
)
}

View File

@@ -12,27 +12,25 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
package iam
import (
"fmt"
"net/http"
authsvc "go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/validator"
)
// SAMLMetadataHandler returns an HTTP handler that serves the SAML Service Provider metadata XML
// Uses global SP certificate configured at service startup
func SAMLMetadataHandler(samlSvc *authsvc.SAMLService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
metadataXML, err := samlSvc.GenerateMetadata()
if err != nil {
http.Error(w, fmt.Sprintf("cannot generate metadata: %v", err), http.StatusInternalServerError)
return
}
func PasswordValidator() validator.ValidatorFunc {
validators := []validator.ValidatorFunc{
validator.NotEmpty(),
validator.MaxLen(255), // Maximum length set to mitigate DDoS attacks
validator.MinLen(8), // Minimum length to prevent weak passwords
}
w.Header().Set("Content-Type", "application/samlmetadata+xml")
w.WriteHeader(http.StatusOK)
w.Write(metadataXML)
return func(value any) *validator.ValidationError {
for _, validator := range validators {
if err := validator(value); err != nil {
return err
}
}
return nil
}
}

View File

@@ -41,6 +41,12 @@ type (
expectedState coredata.DocumentVersionSignatureState
}
ErrDocumentVersionNoChanges struct {
}
ErrDocumentVersionSignatureAlreadySigned struct {
}
CreateDocumentRequest struct {
OrganizationID gid.GID
Title string
@@ -144,6 +150,14 @@ func (e ErrSignatureNotCancellable) Error() string {
e.currentState, e.expectedState)
}
func (e ErrDocumentVersionNoChanges) Error() string {
return "no changes detected"
}
func (e ErrDocumentVersionSignatureAlreadySigned) Error() string {
return "document version signature already signed"
}
func (s *DocumentService) Get(
ctx context.Context,
documentID gid.GID,
@@ -343,9 +357,7 @@ func (s *DocumentService) publishVersionInTx(
if publishedVersion.Content == documentVersion.Content &&
publishedVersion.Title == documentVersion.Title &&
publishedVersion.OwnerID == documentVersion.OwnerID {
return nil, nil, &coredata.ErrDocumentVersionNoChanges{
Message: "no changes detected",
}
return nil, nil, &ErrDocumentVersionNoChanges{}
}
}
@@ -661,7 +673,7 @@ func (s *DocumentService) signDocumentVersionInTx(
}
if documentVersionSignature.State == coredata.DocumentVersionSignatureStateSigned {
return nil, &coredata.ErrDocumentVersionSignatureAlreadySigned{}
return nil, &ErrDocumentVersionSignatureAlreadySigned{}
}
documentVersionSignature.State = coredata.DocumentVersionSignatureStateSigned

View File

@@ -563,41 +563,6 @@ func (s OrganizationService) DeleteHorizontalLogo(
return organization, nil
}
func (s OrganizationService) Delete(
ctx context.Context,
organizationID gid.GID,
) error {
organization := &coredata.Organization{}
err := s.svc.pg.WithTx(
ctx,
func(tx pg.Conn) error {
if err := organization.LoadByID(ctx, tx, s.svc.scope, organizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
// Delete documents first because versions and signatures have a delete restriction on people
// that must be resolved before deleting the organization
document := &coredata.Document{}
if err := document.DeleteByOrganizationID(ctx, tx, s.svc.scope, organizationID); err != nil {
return fmt.Errorf("cannot delete documents: %w", err)
}
if err := organization.Delete(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot delete organization: %w", err)
}
return nil
},
)
if err != nil {
return err
}
return nil
}
func (s OrganizationService) createProboVendor(ctx context.Context, tx pg.Conn, organization *coredata.Organization, now time.Time) error {
proboData := &coredata.Vendor{
ID: gid.New(s.svc.scope.GetTenantID(), coredata.VendorEntityType),

View File

@@ -24,8 +24,6 @@ import (
"go.gearno.de/kit/pg"
"go.gearno.de/x/ref"
"go.probo.inc/probo/pkg/agents"
"go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/authz"
"go.probo.inc/probo/pkg/certmanager"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/crypto/cipher"
@@ -67,8 +65,6 @@ type (
html2pdfConverter *html2pdf.Converter
acmeService *certmanager.ACMEService
fileManager *filemanager.Service
auth *auth.Service
authz *authz.Service
logger *log.Logger
slack *slack.Service
}
@@ -137,8 +133,6 @@ func NewService(
html2pdfConverter *html2pdf.Converter,
acmeService *certmanager.ACMEService,
fileManagerService *filemanager.Service,
authService *auth.Service,
authzService *authz.Service,
logger *log.Logger,
slackService *slack.Service,
) (*Service, error) {
@@ -158,8 +152,6 @@ func NewService(
html2pdfConverter: html2pdfConverter,
acmeService: acmeService,
fileManager: fileManagerService,
auth: authService,
authz: authzService,
logger: logger,
slack: slackService,
}

View File

@@ -25,6 +25,7 @@ type (
Password passwordConfig `json:"password"`
DisableSignup bool `json:"disable-signup"`
InvitationConfirmationTokenValidity int `json:"invitation-confirmation-token-validity"`
PasswordResetTokenValidity int `json:"password-reset-token-validity"`
SAML samlConfig `json:"saml"`
}

View File

@@ -17,8 +17,10 @@ package probod
import (
"context"
"crypto"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"net"
@@ -27,6 +29,8 @@ import (
"sync"
"time"
pemutil "go.probo.inc/probo/pkg/crypto/pem"
"github.com/aws/aws-sdk-go-v2/service/s3"
proxyproto "github.com/pires/go-proxyproto"
"github.com/prometheus/client_golang/prometheus"
@@ -38,8 +42,6 @@ import (
"go.gearno.de/kit/unit"
"go.opentelemetry.io/otel/trace"
"go.probo.inc/probo/pkg/agents"
"go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/authz"
"go.probo.inc/probo/pkg/awsconfig"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/certmanager"
@@ -48,14 +50,13 @@ import (
"go.probo.inc/probo/pkg/crypto/cipher"
"go.probo.inc/probo/pkg/crypto/keys"
"go.probo.inc/probo/pkg/crypto/passwdhash"
"go.probo.inc/probo/pkg/crypto/pem"
"go.probo.inc/probo/pkg/filemanager"
"go.probo.inc/probo/pkg/html2pdf"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/mailer"
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/saferedirect"
"go.probo.inc/probo/pkg/securecookie"
"go.probo.inc/probo/pkg/server"
"go.probo.inc/probo/pkg/server/api"
"go.probo.inc/probo/pkg/slack"
"go.probo.inc/probo/pkg/trust"
"golang.org/x/sync/errgroup"
@@ -123,6 +124,7 @@ func New() *Implm {
},
DisableSignup: false,
InvitationConfirmationTokenValidity: 3600,
PasswordResetTokenValidity: 3600,
SAML: samlConfig{
SessionDuration: 604800,
CleanupIntervalSeconds: 86400,
@@ -277,51 +279,60 @@ func (impl *Implm) Run(
agent := agents.NewAgent(l.Named("agent"), agentConfig)
authService, err := auth.NewService(
ctx,
pgClient,
impl.cfg.EncryptionKey,
hp,
impl.cfg.Auth.Cookie.Secret,
impl.cfg.BaseURL.String(),
impl.cfg.Auth.DisableSignup,
time.Duration(impl.cfg.Auth.InvitationConfirmationTokenValidity)*time.Second,
)
if err != nil {
return fmt.Errorf("cannot create auth service: %w", err)
}
authzService, err := authz.NewService(
ctx,
pgClient,
impl.cfg.BaseURL.String(),
impl.cfg.Auth.Cookie.Secret,
time.Duration(impl.cfg.Auth.InvitationConfirmationTokenValidity)*time.Second,
)
if err != nil {
return fmt.Errorf("cannot create authz service: %w", err)
}
fileManagerService := filemanager.NewService(s3Client)
samlService, err := auth.NewSAMLService(
var samlCert *x509.Certificate
var samlKey *rsa.PrivateKey
if impl.cfg.Auth.SAML.Certificate != "" && impl.cfg.Auth.SAML.PrivateKey != "" {
// Decode certificate
certBlock, _ := pem.Decode([]byte(impl.cfg.Auth.SAML.Certificate))
if certBlock == nil {
return fmt.Errorf("cannot decode SAML certificate PEM block")
}
var err error
samlCert, err = x509.ParseCertificate(certBlock.Bytes)
if err != nil {
return fmt.Errorf("cannot parse SAML certificate: %w", err)
}
// Decode private key
signer, err := pemutil.DecodePrivateKey([]byte(impl.cfg.Auth.SAML.PrivateKey))
if err != nil {
return fmt.Errorf("cannot decode SAML private key: %w", err)
}
var ok bool
samlKey, ok = signer.(*rsa.PrivateKey)
if !ok {
return fmt.Errorf("SAML private key is not an RSA key")
}
}
iamService, err := iam.NewService(
ctx,
pgClient,
impl.cfg.EncryptionKey,
impl.cfg.BaseURL.String(),
impl.cfg.Auth.SAML.SessionDurationTime(),
impl.cfg.Auth.Cookie.Name,
impl.cfg.Auth.Cookie.Secret,
impl.cfg.Auth.SAML.Certificate,
impl.cfg.Auth.SAML.PrivateKey,
l.Named("saml"),
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"),
},
)
if err != nil {
return fmt.Errorf("cannot create SAML service: %w", err)
return fmt.Errorf("cannot create iam service: %w", err)
}
var accountKey crypto.Signer
if impl.cfg.CustomDomains.ACME.AccountKey != "" {
accountKey, err = pem.DecodePrivateKey([]byte(impl.cfg.CustomDomains.ACME.AccountKey))
accountKey, err = pemutil.DecodePrivateKey([]byte(impl.cfg.CustomDomains.ACME.AccountKey))
if err != nil {
return fmt.Errorf("cannot decode ACME account key: %w", err)
}
@@ -370,8 +381,6 @@ func (impl *Implm) Run(
html2pdfConverter,
acmeService,
fileManagerService,
authService,
authzService,
l.Named("probo"),
slackService,
)
@@ -386,7 +395,8 @@ func (impl *Implm) Run(
impl.cfg.BaseURL.String(),
impl.cfg.EncryptionKey,
impl.cfg.TrustAuth.TokenSecret,
authService,
impl.cfg.GetSlackSigningSecret(),
iamService,
html2pdfConverter,
fileManagerService,
l,
@@ -403,40 +413,23 @@ func (impl *Implm) Run(
AllowedOrigins: impl.cfg.Api.Cors.AllowedOrigins,
ExtraHeaderFields: impl.cfg.Api.ExtraHeaderFields,
Probo: proboService,
Auth: authService,
Authz: authzService,
IAM: iamService,
Trust: trustService,
Slack: slackService,
SAML: samlService,
ConnectorRegistry: defaultConnectorRegistry,
BaseURL: impl.cfg.BaseURL,
Agent: agent,
SafeRedirect: &saferedirect.SafeRedirect{AllowedHost: impl.cfg.BaseURL.Host()},
CustomDomainCname: impl.cfg.CustomDomains.CnameTarget,
FileManager: fileManagerService,
PGClient: pgClient,
Logger: l.Named("http.server"),
ConsoleAuth: api.ConsoleAuthConfig{
CookieName: impl.cfg.Auth.Cookie.Name,
CookieDomain: impl.cfg.Auth.Cookie.Domain,
SessionDuration: time.Duration(impl.cfg.Auth.Cookie.Duration) * time.Hour,
CookieSecret: impl.cfg.Auth.Cookie.Secret,
CookieSecure: impl.cfg.Auth.Cookie.Secure,
},
TrustAuth: api.TrustAuthConfig{
CookieName: impl.cfg.TrustAuth.CookieName,
CookieDomain: impl.cfg.TrustAuth.CookieDomain,
CookieDuration: time.Duration(impl.cfg.TrustAuth.CookieDuration) * time.Hour,
TokenDuration: time.Duration(impl.cfg.TrustAuth.TokenDuration) * time.Hour,
ReportURLDuration: time.Duration(impl.cfg.TrustAuth.ReportURLDuration) * time.Minute,
TokenSecret: impl.cfg.TrustAuth.TokenSecret,
Scope: impl.cfg.TrustAuth.Scope,
TokenType: impl.cfg.TrustAuth.TokenType,
CookieSecure: impl.cfg.Auth.Cookie.Secure,
},
MCPConfig: api.MCPConfig{
Version: "0.0.1",
RequestTimeout: 30 * time.Second,
MaxRequestSize: 10 * 1024 * 1024, // 10MB
Cookie: securecookie.Config{
Name: impl.cfg.Auth.Cookie.Name,
Domain: impl.cfg.Auth.Cookie.Domain,
Path: "/",
MaxAge: int(time.Duration(impl.cfg.Auth.Cookie.Duration) * time.Hour),
Secret: impl.cfg.Auth.Cookie.Secret,
Secure: impl.cfg.Auth.Cookie.Secure,
HTTPOnly: true,
SameSite: http.SameSiteLaxMode,
},
},
)
@@ -455,16 +448,20 @@ func (impl *Implm) Run(
)
mailerCtx, stopMailer := context.WithCancel(context.Background())
mailer := mailer.NewMailer(pgClient, l, mailer.Config{
SenderEmail: impl.cfg.Notifications.Mailer.SenderEmail,
SenderName: impl.cfg.Notifications.Mailer.SenderName,
Addr: impl.cfg.Notifications.Mailer.SMTP.Addr,
User: impl.cfg.Notifications.Mailer.SMTP.User,
Password: impl.cfg.Notifications.Mailer.SMTP.Password,
TLSRequired: impl.cfg.Notifications.Mailer.SMTP.TLSRequired,
Timeout: time.Second * 10,
Interval: time.Duration(impl.cfg.Notifications.Mailer.MailerInterval) * time.Second,
})
mailer := mailer.NewMailer(
pgClient,
l,
mailer.Config{
SenderEmail: impl.cfg.Notifications.Mailer.SenderEmail,
SenderName: impl.cfg.Notifications.Mailer.SenderName,
Addr: impl.cfg.Notifications.Mailer.SMTP.Addr,
User: impl.cfg.Notifications.Mailer.SMTP.User,
Password: impl.cfg.Notifications.Mailer.SMTP.Password,
TLSRequired: impl.cfg.Notifications.Mailer.SMTP.TLSRequired,
Timeout: time.Second * 10,
Interval: time.Duration(impl.cfg.Notifications.Mailer.MailerInterval) * time.Second,
},
)
wg.Go(
func() {
if err := mailer.Run(mailerCtx); err != nil {
@@ -494,16 +491,13 @@ func (impl *Implm) Run(
},
)
samlCleanerCtx, stopSAMLCleaner := context.WithCancel(context.Background())
samlCleaner := auth.NewCleaner(
pgClient,
impl.cfg.Auth.SAML.CleanupInterval(),
l.Named("saml-cleaner"),
)
samlServiceCtx, stopSAMLService := context.WithCancel(context.Background())
wg.Go(
func() {
if err := samlCleaner.Run(samlCleanerCtx); err != nil {
cancel(fmt.Errorf("saml cleaner crashed: %w", err))
if iamService.SAMLService != nil {
if err := iamService.SAMLService.Run(samlServiceCtx); err != nil {
cancel(fmt.Errorf("saml service crashed: %w", err))
}
}
},
)
@@ -523,7 +517,7 @@ func (impl *Implm) Run(
stopMailer()
stopSlackSender()
stopExportJobExporter()
stopSAMLCleaner()
stopSAMLService()
stopApiServer()
stopTrustCenterServer()

View File

@@ -0,0 +1,91 @@
// 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 securetoken
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"net/http"
"strings"
"go.probo.inc/probo/pkg/bearertoken"
)
var (
ErrInvalidToken = errors.New("invalid token")
ErrTokenNotFound = errors.New("token not found")
ErrInvalidSignature = errors.New("invalid signature")
)
func Get(req *http.Request, secret string) (string, error) {
v := req.Header.Get("Authorization")
if v == "" {
return "", ErrTokenNotFound
}
token, err := bearertoken.Parse(v)
if err != nil {
return "", ErrInvalidToken
}
value, err := Verify(token, secret)
if err != nil {
return "", ErrInvalidToken
}
return value, nil
}
// Sign creates a signed value using HMAC-SHA256
func Sign(value, secret string) (string, error) {
if secret == "" {
return "", fmt.Errorf("secret cannot be empty")
}
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(value))
signature := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
return value + "." + signature, nil
}
// Verify checks if a signed value is valid
func Verify(signedValue, secret string) (string, error) {
if secret == "" {
return "", fmt.Errorf("secret cannot be empty")
}
parts := strings.Split(signedValue, ".")
if len(parts) != 2 {
return "", fmt.Errorf("invalid signed value format")
}
value := parts[0]
expectedSignedValue, err := Sign(value, secret)
if err != nil {
return "", fmt.Errorf("cannot sign value: %w", err)
}
if signedValue != expectedSignedValue {
return "", ErrInvalidSignature
}
return value, nil
}

View File

@@ -21,14 +21,14 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/cors"
"github.com/vektah/gqlparser/v2/ast"
"go.gearno.de/kit/httpserver"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/auth"
"go.probo.inc/probo/pkg/authz"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/connector"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/probo"
"go.probo.inc/probo/pkg/saferedirect"
"go.probo.inc/probo/pkg/securecookie"
connect_v1 "go.probo.inc/probo/pkg/server/api/connect/v1"
console_v1 "go.probo.inc/probo/pkg/server/api/console/v1"
mcp_v1 "go.probo.inc/probo/pkg/server/api/mcp/v1"
slack_v1 "go.probo.inc/probo/pkg/server/api/slack/v1"
@@ -38,39 +38,16 @@ import (
)
type (
ConsoleAuthConfig struct {
CookieName string
CookieDomain string
SessionDuration time.Duration
CookieSecret string
CookieSecure bool
}
TrustAuthConfig struct {
CookieName string
CookieDomain string
CookieDuration time.Duration
TokenDuration time.Duration
ReportURLDuration time.Duration
TokenSecret string
Scope string
TokenType string
CookieSecure bool
}
Config struct {
BaseURL *baseurl.BaseURL
AllowedOrigins []string
Probo *probo.Service
Auth *auth.Service
Authz *authz.Service
IAM *iam.Service
Trust *trust.Service
Slack *slack.Service
SAML *auth.SAMLService
ConsoleAuth ConsoleAuthConfig
TrustAuth TrustAuthConfig
MCPConfig MCPConfig
Cookie securecookie.Config
TokenSecret string
ConnectorRegistry *connector.ConnectorRegistry
SafeRedirect *saferedirect.SafeRedirect
CustomDomainCname string
Logger *log.Logger
}
@@ -82,26 +59,21 @@ type (
}
Server struct {
cfg Config
trustAPIHandler http.Handler
consoleAPIHandler http.Handler
mcpAPIHandler http.Handler
slackAPIHandler http.Handler
cfg Config
compliancePageHandler http.Handler
consoleHandler http.Handler
mcpHandler http.Handler
slackHandler http.Handler
connectHandler http.Handler
}
)
var (
ErrMissingProboService = errors.New("server configuration requires a valid probo.Service instance")
ErrMissingAuthService = errors.New("server configuration requires a valid auth.Service instance")
ErrMissingAuthzService = errors.New("server configuration requires a valid authz.Service instance")
ErrMissingIAMService = errors.New("server configuration requires a valid iam.Service instance")
ErrMissingSlackService = errors.New("server configuration requires a valid slack.Service instance")
)
// GetConsoleSchema returns the GraphQL schema for the console API
func GetConsoleSchema() *ast.Schema {
return console_v1.GetSchema()
}
func methodNotAllowed(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
@@ -131,91 +103,53 @@ func NewServer(cfg Config) (*Server, error) {
return nil, ErrMissingProboService
}
if cfg.Auth == nil {
return nil, ErrMissingAuthService
}
if cfg.Authz == nil {
return nil, ErrMissingAuthzService
if cfg.IAM == nil {
return nil, ErrMissingIAMService
}
if cfg.Slack == nil {
return nil, ErrMissingSlackService
}
trustAPIHandler := trust_v1.NewMux(
cfg.Logger.Named("trust.v1"),
cfg.Auth,
cfg.Authz,
cfg.Trust,
console_v1.AuthConfig{
CookieName: cfg.ConsoleAuth.CookieName,
CookieDomain: cfg.ConsoleAuth.CookieDomain,
SessionDuration: cfg.ConsoleAuth.SessionDuration,
CookieSecret: cfg.ConsoleAuth.CookieSecret,
CookieSecure: cfg.ConsoleAuth.CookieSecure,
},
trust_v1.TrustAuthConfig{
CookieName: cfg.TrustAuth.CookieName,
CookieDomain: cfg.TrustAuth.CookieDomain,
CookieDuration: cfg.TrustAuth.CookieDuration,
TokenDuration: cfg.TrustAuth.TokenDuration,
ReportURLDuration: cfg.TrustAuth.ReportURLDuration,
TokenSecret: cfg.TrustAuth.TokenSecret,
Scope: cfg.TrustAuth.Scope,
TokenType: cfg.TrustAuth.TokenType,
CookieSecure: cfg.TrustAuth.CookieSecure,
},
cfg.Slack,
)
consoleAPIHandler := console_v1.NewMux(
cfg.Logger.Named("console.v1"),
cfg.Probo,
cfg.Auth,
cfg.Authz,
console_v1.AuthConfig{
CookieName: cfg.ConsoleAuth.CookieName,
CookieDomain: cfg.ConsoleAuth.CookieDomain,
SessionDuration: cfg.ConsoleAuth.SessionDuration,
CookieSecret: cfg.ConsoleAuth.CookieSecret,
CookieSecure: cfg.ConsoleAuth.CookieSecure,
},
cfg.ConnectorRegistry,
cfg.SafeRedirect,
cfg.CustomDomainCname,
cfg.SAML,
)
mcpAPIHandler := mcp_v1.NewMux(
cfg.Logger.Named("mcp.v1"),
cfg.Probo,
cfg.Auth,
cfg.Authz,
mcp_v1.Config{
Version: cfg.MCPConfig.Version,
RequestTimeout: cfg.MCPConfig.RequestTimeout,
MaxRequestSize: cfg.MCPConfig.MaxRequestSize,
},
)
slackAPIHandler := slack_v1.NewMux(
cfg.Logger.Named("slack.v1"),
cfg.Slack,
cfg.Trust,
)
return &Server{
cfg: cfg,
trustAPIHandler: trustAPIHandler,
consoleAPIHandler: consoleAPIHandler,
mcpAPIHandler: mcpAPIHandler,
slackAPIHandler: slackAPIHandler,
cfg: cfg,
compliancePageHandler: trust_v1.NewMux(
cfg.Logger.Named("trust.v1"),
cfg.IAM,
cfg.Trust,
cfg.Cookie,
),
consoleHandler: console_v1.NewMux(
cfg.Logger.Named("console.v1"),
cfg.Probo,
cfg.IAM,
cfg.Cookie,
cfg.TokenSecret,
cfg.ConnectorRegistry,
cfg.BaseURL,
cfg.CustomDomainCname,
),
mcpHandler: mcp_v1.NewMux(
cfg.Logger.Named("mcp.v1"),
cfg.Probo,
cfg.IAM,
),
slackHandler: slack_v1.NewMux(
cfg.Logger.Named("slack.v1"),
cfg.Slack,
cfg.Trust,
),
connectHandler: connect_v1.NewMux(
cfg.Logger.Named("connect.v1"),
cfg.IAM,
cfg.Cookie,
cfg.BaseURL,
),
}, nil
}
func (s *Server) TrustAPIHandler() http.Handler {
return s.trustAPIHandler
func (s *Server) CompliancePageHandler() http.Handler {
return s.compliancePageHandler
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -223,7 +157,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
AllowedOrigins: s.cfg.AllowedOrigins,
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "HEAD"},
AllowedHeaders: []string{"content-type", "traceparent", "authorization"},
ExposedHeaders: []string{"x-Request-id"},
ExposedHeaders: []string{"x-request-id"},
AllowCredentials: true,
MaxAge: 600, // 10 minutes (chrome >= 76 maximum value c.f. https://source.chromium.org/chromium/chromium/src/+/main:services/network/public/cpp/cors/preflight_result.cc;drc=52002151773d8cd9ffc5f557cd7cc880fddcae3e;l=36)
OptionsPassthrough: false,
@@ -243,10 +177,11 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
router.Use(cors.Handler(corsOpts))
router.Mount("/console/v1", s.consoleAPIHandler)
router.Mount("/trust/v1", s.trustAPIHandler)
router.Mount("/mcp/v1", s.mcpAPIHandler)
router.Mount("/slack/v1", s.slackAPIHandler)
router.Mount("/console/v1", s.consoleHandler)
router.Mount("/connect/v1", s.connectHandler)
router.Mount("/trust/v1", s.compliancePageHandler)
router.Mount("/mcp/v1", s.mcpHandler)
router.Mount("/slack/v1", s.slackHandler)
router.ServeHTTP(w, r)
}

View File

@@ -0,0 +1,94 @@
// 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 connect_v1
import (
"context"
"errors"
"fmt"
"net/http"
"go.gearno.de/kit/httpserver"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/securetoken"
)
var (
apiKeyContextKey = &ctxKey{name: "api_key"}
)
func APIKeyFromContext(ctx context.Context) *coredata.UserAPIKey {
apiKey, _ := ctx.Value(apiKeyContextKey).(*coredata.UserAPIKey)
return apiKey
}
func NewAPIKeyMiddleware(svc *iam.Service) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
session := SessionFromContext(ctx)
if session != nil {
httpserver.RenderError(w, http.StatusBadRequest, errors.New("api key authentication cannot be used with session authentication"))
return
}
tokenValue, err := securetoken.Get(r, "")
if err != nil {
next.ServeHTTP(w, r)
return
}
keyID, err := gid.ParseGID(tokenValue)
if err != nil {
next.ServeHTTP(w, r)
return
}
apiKey, err := svc.APIKeyService.GetAPIKey(ctx, keyID)
if err != nil {
var errUserAPIKeyNotFound *iam.ErrUserAPIKeyNotFound
var errUserAPIKeyExpired *iam.ErrUserAPIKeyExpired
if errors.As(err, &errUserAPIKeyNotFound) || errors.As(err, &errUserAPIKeyExpired) {
next.ServeHTTP(w, r)
return
}
panic(fmt.Errorf("cannot get user API key: %w", err))
}
user, err := svc.AccountService.GetIdentity(ctx, apiKey.UserID)
if err != nil {
var errUserNotFound *iam.ErrUserNotFound
if errors.As(err, &errUserNotFound) {
next.ServeHTTP(w, r)
return
}
panic(fmt.Errorf("cannot get user: %w", err))
}
ctx = context.WithValue(ctx, apiKeyContextKey, apiKey)
ctx = context.WithValue(ctx, identityContextKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
},
)
}
}

View File

@@ -0,0 +1,19 @@
// 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 connect_v1
type (
ctxKey struct{ name string }
)

View File

@@ -0,0 +1,36 @@
schema: ["schema.graphql"]
exec:
filename: "schema/schema.go"
package: "schema"
model:
filename: "types/types.go"
package: "types"
resolver:
layout: "follow-schema"
dir: "."
package: "connect_v1"
filename_template: "v1_resolver.go"
autobind: []
call_argument_directives_with_null: true
directives:
mustBeAuthorized:
skip_runtime: false
models:
ID:
model:
- "go.probo.inc/probo/pkg/server/gqlutils/types/gid.GIDScalar"
Datetime:
model:
- "github.com/99designs/gqlgen/graphql.Time"
CursorKey:
model:
- "go.probo.inc/probo/pkg/server/gqlutils/types/cursor.CursorKeyScalar"
EmailAddr:
model:
- "go.probo.inc/probo/pkg/server/gqlutils/types/mail.AddrScalar"

View File

@@ -0,0 +1,102 @@
// 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 connect_v1
import (
"context"
"fmt"
"net/http"
"github.com/99designs/gqlgen/graphql"
"github.com/vektah/gqlparser/v2/gqlerror"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/securecookie"
"go.probo.inc/probo/pkg/server/api/connect/v1/schema"
"go.probo.inc/probo/pkg/server/api/connect/v1/types"
"go.probo.inc/probo/pkg/server/gqlutils"
)
var (
ErrForbidden = &gqlerror.Error{
Message: "You are not authorized to access this resource",
Extensions: map[string]any{
"code": "FORBIDDEN",
},
}
ErrUnauthorized = &gqlerror.Error{
Message: "You are not authorized to access this resource",
Extensions: map[string]any{
"code": "UNAUTHORIZED",
},
}
ErrAlreadyAuthenticated = &gqlerror.Error{
Message: "authentication not allowed for this resource/action",
Extensions: map[string]any{
"code": "ALREADY_AUTHENTICATED",
},
}
)
func SessionDirective(ctx context.Context, obj any, next graphql.Resolver, required types.SessionRequirement) (any, error) {
session := SessionFromContext(ctx)
switch required {
case types.SessionRequirementOptional:
case types.SessionRequirementPresent:
if session == nil {
return nil, ErrUnauthorized
}
case types.SessionRequirementNone:
if session != nil {
return nil, ErrAlreadyAuthenticated
}
}
return next(ctx)
}
func IsViewerDirective(ctx context.Context, obj any, next graphql.Resolver) (any, error) {
identity := UserFromContext(ctx)
resolvedIdentity, ok := obj.(*types.Identity)
if !ok {
panic(fmt.Errorf("@isViewer called on non-identity object: %T", obj))
}
if identity.ID != resolvedIdentity.ID {
return nil, ErrForbidden
}
return next(ctx)
}
func NewGraphQLHandler(svc *iam.Service, logger *log.Logger, cookieConfig securecookie.Config) http.Handler {
config := schema.Config{
Resolvers: &Resolver{
iam: svc,
cookieConfig: cookieConfig,
},
Directives: schema.DirectiveRoot{
Session: SessionDirective,
IsViewer: IsViewerDirective,
},
}
es := schema.NewExecutableSchema(config)
gqlh := gqlutils.NewHandler(es, logger)
return gqlh
}

View File

@@ -0,0 +1,51 @@
// 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 connect_v1
import (
"context"
"net/http"
)
var (
httpResponseWriterKey = &ctxKey{name: "http_response_writer"}
httpRequestKey = &ctxKey{name: "http_request"}
)
func HTTPContextMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
ctx := WithHTTPContext(r.Context(), w, r)
next.ServeHTTP(w, r.WithContext(ctx))
},
)
}
func WithHTTPContext(ctx context.Context, w http.ResponseWriter, r *http.Request) context.Context {
ctx = context.WithValue(ctx, httpResponseWriterKey, w)
ctx = context.WithValue(ctx, httpRequestKey, r)
return ctx
}
func HTTPResponseWriterFromContext(ctx context.Context) http.ResponseWriter {
return ctx.Value(httpResponseWriterKey).(http.ResponseWriter)
}
func HTTPRequestFromContext(ctx context.Context) *http.Request {
return ctx.Value(httpRequestKey).(*http.Request)
}

View File

@@ -0,0 +1,66 @@
//go:generate go run github.com/99designs/gqlgen generate
// 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 connect_v1
import (
"time"
"github.com/go-chi/chi/v5"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/securecookie"
)
type (
Resolver struct {
iam *iam.Service
cookieConfig securecookie.Config
}
)
func (r *Resolver) sessionCookieConfig(maxAge time.Duration) securecookie.Config {
return securecookie.Config{
Name: r.cookieConfig.Name,
Secret: r.cookieConfig.Secret,
Secure: r.cookieConfig.Secure,
HTTPOnly: r.cookieConfig.HTTPOnly,
SameSite: r.cookieConfig.SameSite,
Path: r.cookieConfig.Path,
Domain: r.cookieConfig.Domain,
MaxAge: int(maxAge.Seconds()),
}
}
func NewMux(logger *log.Logger, svc *iam.Service, cookieConfig securecookie.Config, baseURL *baseurl.BaseURL) *chi.Mux {
r := chi.NewMux()
r.Use(HTTPContextMiddleware)
sessionMiddleware := NewSessionMiddleware(svc, cookieConfig)
graphqlHandler := NewGraphQLHandler(svc, logger, cookieConfig)
samlHandler := NewSAMLHandler(svc, cookieConfig, baseURL)
router := r.With(sessionMiddleware)
router.Handle("/graphql", graphqlHandler)
router.Get("/saml/2.0/metadata", samlHandler.MetadataHandler)
router.Post("/saml/2.0/consume", samlHandler.ConsumeHandler)
router.Get("/saml/2.0/{samlConfigID}", samlHandler.LoginHandler)
return r
}

View File

@@ -0,0 +1,96 @@
package connect_v1
import (
"errors"
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"go.gearno.de/kit/httpserver"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/securecookie"
)
type SAMLHandler struct {
iam *iam.Service
cookieConfig securecookie.Config
baseURL *baseurl.BaseURL
}
func NewSAMLHandler(iam *iam.Service, cookieConfig securecookie.Config, baseURL *baseurl.BaseURL) *SAMLHandler {
return &SAMLHandler{iam: iam, cookieConfig: cookieConfig, baseURL: baseURL}
}
func (h *SAMLHandler) MetadataHandler(w http.ResponseWriter, r *http.Request) {
metadataXML, err := h.iam.SAMLService.GenerateSpMetadata()
if err != nil {
panic(fmt.Errorf("cannot generate metadata: %w", err))
}
w.Header().Set("Content-Type", "application/samlmetadata+xml")
w.WriteHeader(http.StatusOK)
w.Write(metadataXML)
}
func (h *SAMLHandler) ConsumeHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
err := r.ParseForm()
if err != nil {
httpserver.RenderError(w, http.StatusBadRequest, errors.New("cannot parse form"))
return
}
samlResponse := r.FormValue("SAMLResponse")
relayState := r.FormValue("RelayState")
configID, err := gid.ParseGID(relayState)
if err != nil {
httpserver.RenderError(w, http.StatusBadRequest, errors.New("invalid relay state"))
return
}
user, membership, err := h.iam.SAMLService.HandleAssertion(ctx, samlResponse, configID)
if err != nil {
httpserver.RenderError(w, http.StatusUnauthorized, err)
return
}
session := SessionFromContext(ctx)
if session == nil {
h.iam.AuthService.OpenSessionWithoutPassword(ctx, user.ID, membership.OrganizationID)
}
// TODO open or update the organization session
securecookie.Set(w, h.cookieConfig, session.ID.String())
redirectURL := h.baseURL.WithPath("/organizations/" + membership.OrganizationID.String()).MustString()
http.Redirect(w, r, redirectURL, http.StatusFound)
}
func (h *SAMLHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
samlConfigIDParam := chi.URLParam(r, "samlConfigID")
if samlConfigIDParam == "" {
httpserver.RenderError(w, http.StatusBadRequest, errors.New("missing SAML config ID"))
return
}
samlConfigID, err := gid.ParseGID(samlConfigIDParam)
if err != nil {
httpserver.RenderError(w, http.StatusBadRequest, errors.New("invalid SAML config ID"))
return
}
url, err := h.iam.SAMLService.InitiateLogin(ctx, samlConfigID)
if err != nil {
panic(fmt.Errorf("cannot initiate SAML login: %w", err))
}
http.Redirect(w, r, url.String(), http.StatusFound)
}

View File

@@ -0,0 +1,769 @@
directive @goField(
forceResolver: Boolean
name: String
omittable: Boolean
) on INPUT_FIELD_DEFINITION | FIELD_DEFINITION
directive @goModel(
model: String
models: [String!]
) on OBJECT | INPUT_OBJECT | SCALAR | ENUM | INTERFACE | UNION
directive @goEnum(value: String) on ENUM_VALUE
directive @session(required: SessionRequirement!) on FIELD_DEFINITION
directive @isViewer on FIELD_DEFINITION
scalar CursorKey
scalar Datetime
scalar Upload
scalar EmailAddr
enum SessionRequirement {
PRESENT
NONE
OPTIONAL
}
enum OrderDirection
@goModel(model: "go.probo.inc/probo/pkg/page.OrderDirection") {
ASC @goEnum(value: "go.probo.inc/probo/pkg/page.OrderDirectionAsc")
DESC @goEnum(value: "go.probo.inc/probo/pkg/page.OrderDirectionDesc")
}
enum SessionOrderField
@goModel(model: "go.probo.inc/probo/pkg/coredata.SessionOrderField") {
CREATED_AT
@goEnum(value: "go.probo.inc/probo/pkg/coredata.SessionOrderFieldCreatedAt")
EXPIRED_AT
@goEnum(value: "go.probo.inc/probo/pkg/coredata.SessionOrderFieldExpiredAt")
UPDATED_AT
@goEnum(value: "go.probo.inc/probo/pkg/coredata.SessionOrderFieldUpdatedAt")
}
input SessionOrder {
direction: OrderDirection!
field: SessionOrderField!
}
interface Node {
id: ID!
}
type Query {
node(id: ID!): Node @session(required: PRESENT)
viewer: Identity @session(required: PRESENT)
checkSSOAvailability(email: String!): SSOAvailability!
@session(required: NONE)
}
type Mutation {
signIn(input: SignInInput!): SignInPayload! @session(required: NONE)
signUp(input: SignUpInput!): SignUpPayload! @session(required: NONE)
signOut: SignOutPayload! @session(required: PRESENT)
signUpFromInvitation(
input: SignUpFromInvitationInput!
): SignUpFromInvitationPayload! @session(required: NONE)
forgotPassword(input: ForgotPasswordInput!): ForgotPasswordPayload!
@session(required: NONE)
resetPassword(input: ResetPasswordInput!): ResetPasswordPayload!
@session(required: NONE)
verifyEmail(input: VerifyEmailInput!): VerifyEmailPayload!
@session(required: OPTIONAL)
changePassword(input: ChangePasswordInput!): ChangePasswordPayload!
@session(required: PRESENT)
changeEmail(input: ChangeEmailInput!): ChangeEmailPayload!
@session(required: PRESENT)
updateIdentityProfile(
input: UpdateIdentityProfileInput!
): UpdateIdentityProfilePayload! @session(required: PRESENT)
revokeSession(input: RevokeSessionInput!): RevokeSessionPayload!
@session(required: PRESENT)
revokeAllSessions: RevokeAllSessionsPayload! @session(required: PRESENT)
createPersonalAPIKey(
input: CreatePersonalAPIKeyInput!
): CreatePersonalAPIKeyPayload! @session(required: PRESENT)
updatePersonalAPIKey(
input: UpdatePersonalAPIKeyInput!
): UpdatePersonalAPIKeyPayload! @session(required: PRESENT)
revokePersonalAPIKey(
input: RevokePersonalAPIKeyInput!
): RevokePersonalAPIKeyPayload! @session(required: PRESENT)
createOrganization(
input: CreateOrganizationInput!
): CreateOrganizationPayload! @session(required: PRESENT)
updateOrganization(
input: UpdateOrganizationInput!
): UpdateOrganizationPayload! @session(required: PRESENT)
deleteOrganization(
input: DeleteOrganizationInput!
): DeleteOrganizationPayload! @session(required: PRESENT)
inviteMember(input: InviteMemberInput!): InviteMemberPayload!
@session(required: PRESENT)
deleteInvitation(input: DeleteInvitationInput!): DeleteInvitationPayload!
@session(required: PRESENT)
removeMember(input: RemoveMemberInput!): RemoveMemberPayload!
@session(required: PRESENT)
acceptInvitation(input: AcceptInvitationInput!): AcceptInvitationPayload!
@session(required: PRESENT)
createSAMLConfiguration(
input: CreateSAMLConfigurationInput!
): CreateSAMLConfigurationPayload! @session(required: PRESENT)
updateSAMLConfiguration(
input: UpdateSAMLConfigurationInput!
): UpdateSAMLConfigurationPayload! @session(required: PRESENT)
deleteSAMLConfiguration(
input: DeleteSAMLConfigurationInput!
): DeleteSAMLConfigurationPayload! @session(required: PRESENT)
}
type Identity implements Node {
id: ID!
email: EmailAddr!
emailVerified: Boolean!
createdAt: Datetime!
updatedAt: Datetime!
memberships(
first: Int
after: CursorKey
last: Int
before: CursorKey
): MembershipConnection! @goField(forceResolver: true) @isViewer
pendingInvitations(
first: Int
after: CursorKey
last: Int
before: CursorKey
): InvitationConnection! @goField(forceResolver: true) @isViewer
sessions(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: SessionOrder
): SessionConnection! @goField(forceResolver: true) @isViewer
personalAPIKeys(
first: Int
after: CursorKey
last: Int
before: CursorKey
): PersonalAPIKeyConnection! @goField(forceResolver: true) @isViewer
profileFor(organizationId: ID!): IdentityProfile @isViewer
}
type IdentityProfile implements Node {
id: ID!
displayName: String!
firstName: String
lastName: String
jobTitle: String
department: String
phoneNumber: String
avatarUrl: String
manager: IdentityProfile
timezone: String
locale: String
customAttributes: [CustomAttribute!]!
provisionedBy: ProvisioningSource!
externalId: String
identity: Identity!
organization: Organization!
createdAt: Datetime!
updatedAt: Datetime!
}
type CustomAttribute {
key: String!
value: String!
}
type Organization implements Node {
id: ID!
name: String!
logoUrl: String @goField(forceResolver: true)
horizontalLogoUrl: String @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
members(
first: Int
after: CursorKey
last: Int
before: CursorKey
): MembershipConnection! @goField(forceResolver: true)
invitations(
first: Int
after: CursorKey
last: Int
before: CursorKey
status: InvitationStatus
): InvitationConnection! @goField(forceResolver: true)
samlConfigurations(
first: Int
after: CursorKey
last: Int
before: CursorKey
): SAMLConfigurationConnection! @goField(forceResolver: true)
availableApplications: [Application!]!
}
type Membership implements Node {
id: ID!
createdAt: Datetime!
profile: IdentityProfile!
identity: Identity! @goField(forceResolver: true)
organization: Organization! @goField(forceResolver: true)
permissions: [Permission!]!
provisionedBy: ProvisioningSource!
active: Boolean!
lastSyncedAt: Datetime
}
type Invitation implements Node {
id: ID!
email: EmailAddr!
expiresAt: Datetime!
acceptedAt: Datetime
createdAt: Datetime!
status: InvitationStatus!
}
type InvitationProfile {
displayName: String!
firstName: String
lastName: String
jobTitle: String
department: String
}
type Session implements Node {
id: ID!
ipAddress: String!
userAgent: String!
updatedAt: Datetime!
createdAt: Datetime!
expiresAt: Datetime!
}
type PersonalAPIKey implements Node {
id: ID!
name: String!
lastUsedAt: Datetime
expiresAt: Datetime!
createdAt: Datetime!
scopes: [TokenScope!]!
organizations: [Organization!]!
}
type Permission implements Node {
id: ID!
createdAt: Datetime!
application: Application!
accessLevel: AccessLevel!
organization: Organization!
principalType: PrincipalType!
principalId: ID!
}
type PermissionGrant {
application: Application!
accessLevel: AccessLevel!
}
type Application {
id: ApplicationId!
name: String!
description: String!
availableAccessLevels: [AccessLevel!]!
}
type SessionPolicy {
maxSessionDurationHours: Int!
idleTimeoutMinutes: Int!
maxConcurrentSessions: Int
requireReauthForSensitiveActions: Boolean!
}
type SAMLConfiguration implements Node {
id: ID!
emailDomain: String!
enabled: Boolean!
enforcementPolicy: SAMLEnforcementPolicy!
domainVerified: Boolean!
domainVerifiedAt: Datetime
domainVerificationToken: String
idpEntityId: String!
idpSsoUrl: String!
idpCertificate: String!
autoSignupEnabled: Boolean!
createdAt: Datetime!
updatedAt: Datetime!
spMetadataUrl: String!
testLoginUrl: String!
attributeMappings: SAMLAttributeMappings!
defaultPermissions: [PermissionGrant!]!
}
type SAMLAttributeMappings {
email: String!
firstName: String!
lastName: String!
role: String!
}
type SSOAvailability {
available: Boolean!
samlConfigId: ID
organizationId: ID
}
enum ApplicationId {
CONSOLE
COMPLIANCE
RISK
VENDOR
DOCUMENTS
TRUST_CENTER
SETTINGS
API
}
enum AccessLevel {
READ
WRITE
ADMIN
}
enum PrincipalType {
IDENTITY
SERVICE_ACCOUNT
}
enum InvitationStatus
@goModel(model: "go.probo.inc/probo/pkg/coredata.InvitationStatus") {
PENDING
@goEnum(value: "go.probo.inc/probo/pkg/coredata.InvitationStatusPending")
ACCEPTED
@goEnum(value: "go.probo.inc/probo/pkg/coredata.InvitationStatusAccepted")
EXPIRED
@goEnum(value: "go.probo.inc/probo/pkg/coredata.InvitationStatusExpired")
}
enum SAMLEnforcementPolicy
@goModel(model: "go.probo.inc/probo/pkg/coredata.SAMLEnforcementPolicy") {
OFF @goEnum(value: "go.probo.inc/probo/pkg/coredata.SAMLEnforcementPolicyOff")
OPTIONAL
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.SAMLEnforcementPolicyOptional"
)
REQUIRED
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.SAMLEnforcementPolicyRequired"
)
}
enum AuthMethod {
PASSWORD
SAML
RECOVERY_CODE
}
enum TokenScope {
READ_ORGANIZATION
WRITE_ORGANIZATION
READ_COMPLIANCE
WRITE_COMPLIANCE
READ_RISK
WRITE_RISK
READ_VENDOR
WRITE_VENDOR
READ_DOCUMENTS
WRITE_DOCUMENTS
READ_TRUST_CENTER
WRITE_TRUST_CENTER
ADMIN
}
enum ProvisioningSource {
MANUAL
INVITATION
SAML
}
type MembershipConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/connect/v1/types.MembershipConnection"
) {
edges: [MembershipEdge!]!
pageInfo: PageInfo!
totalCount: Int! @goField(forceResolver: true)
}
type MembershipEdge {
node: Membership!
cursor: CursorKey!
}
type InvitationConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/connect/v1/types.InvitationConnection"
) {
edges: [InvitationEdge!]!
pageInfo: PageInfo!
totalCount: Int! @goField(forceResolver: true)
}
type InvitationEdge {
node: Invitation!
cursor: CursorKey!
}
type SessionConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/connect/v1/types.SessionConnection"
) {
edges: [SessionEdge!]!
pageInfo: PageInfo!
totalCount: Int! @goField(forceResolver: true)
}
type SessionEdge {
node: Session!
cursor: CursorKey!
}
type PersonalAPIKeyConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/connect/v1/types.PersonalAPIKeyConnection"
) {
edges: [PersonalAPIKeyEdge!]!
pageInfo: PageInfo!
totalCount: Int! @goField(forceResolver: true)
}
type PersonalAPIKeyEdge {
node: PersonalAPIKey!
cursor: CursorKey!
}
type SAMLConfigurationConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/connect/v1/types.SAMLConfigurationConnection"
) {
edges: [SAMLConfigurationEdge!]!
pageInfo: PageInfo!
totalCount: Int! @goField(forceResolver: true)
}
type SAMLConfigurationEdge {
node: SAMLConfiguration!
cursor: CursorKey!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: CursorKey
endCursor: CursorKey
}
input SignInInput {
email: EmailAddr!
password: String!
}
input SignUpInput {
email: EmailAddr!
password: String!
fullName: String!
}
input SignUpFromInvitationInput {
token: String!
password: String!
}
input ForgotPasswordInput {
email: EmailAddr!
}
input ResetPasswordInput {
token: String!
password: String!
}
input VerifyEmailInput {
token: String!
}
input ChangePasswordInput {
currentPassword: String!
newPassword: String!
}
input ChangeEmailInput {
newEmail: EmailAddr!
password: String!
}
input DeactivateAccountInput {
password: String!
}
input DeleteAccountInput {
password: String!
confirmation: String!
}
input UpdateIdentityProfileInput {
membershipId: ID!
displayName: String
firstName: String
lastName: String
jobTitle: String
department: String
phoneNumber: String
timezone: String
locale: String
}
input RevokeSessionInput {
sessionId: ID!
}
input CreatePersonalAPIKeyInput {
name: String!
expiresAt: Datetime!
organizationIds: [ID!]!
}
input UpdatePersonalAPIKeyInput {
tokenId: ID!
name: String
description: String
}
input RevokePersonalAPIKeyInput {
tokenId: ID!
}
input CreateOrganizationInput {
name: String!
logoFile: Upload
horizontalLogoFile: Upload
}
input UpdateOrganizationInput {
organizationId: ID!
name: String
logoFile: Upload @goField(omittable: true)
horizontalLogoFile: Upload @goField(omittable: true)
}
input DeleteOrganizationInput {
organizationId: ID!
}
input SessionPolicyInput {
maxSessionDurationHours: Int
idleTimeoutMinutes: Int
maxConcurrentSessions: Int
requireReauthForSensitiveActions: Boolean
}
input AddIPAllowlistEntryInput {
organizationId: ID!
cidr: String!
description: String
}
input RemoveIPAllowlistEntryInput {
entryId: ID!
}
input InviteMemberInput {
organizationId: ID!
email: EmailAddr!
fullName: String!
}
input RemoveMemberInput {
organizationId: ID!
membershipId: ID!
}
input InvitationProfileInput {
displayName: String!
firstName: String
lastName: String
jobTitle: String
department: String
}
input AcceptInvitationInput {
invitationId: ID!
}
input DeleteInvitationInput {
organizationId: ID!
invitationId: ID!
}
input CreateSAMLConfigurationInput {
organizationId: ID!
emailDomain: String!
idpEntityId: String!
idpSsoUrl: String!
idpCertificate: String!
autoSignupEnabled: Boolean!
attributeMappings: SAMLAttributeMappingsInput
}
input SAMLAttributeMappingsInput {
email: String
firstName: String
lastName: String
role: String
}
input UpdateSAMLConfigurationInput {
organizationId: ID!
samlConfigurationId: ID!
idpEntityId: String
idpSsoUrl: String
idpCertificate: String
autoSignupEnabled: Boolean
enforcementPolicy: SAMLEnforcementPolicy
attributeMappings: SAMLAttributeMappingsInput
}
input DeleteSAMLConfigurationInput {
organizationId: ID!
samlConfigurationId: ID!
}
type SignInPayload {
identity: Identity
}
type SignUpPayload {
identity: Identity
}
type SignOutPayload {
success: Boolean!
}
type SignUpFromInvitationPayload {
identity: Identity
}
type ForgotPasswordPayload {
success: Boolean!
}
type ResetPasswordPayload {
success: Boolean!
}
type VerifyEmailPayload {
success: Boolean!
}
type ChangePasswordPayload {
success: Boolean!
}
type ChangeEmailPayload {
success: Boolean!
}
type DeactivateAccountPayload {
success: Boolean!
}
type DeleteAccountPayload {
success: Boolean!
}
type UpdateIdentityProfilePayload {
profile: IdentityProfile
}
type RevokeSessionPayload {
success: Boolean!
}
type RevokeAllSessionsPayload {
revokedCount: Int!
}
type CreatePersonalAPIKeyPayload {
personalAPIKeyEdge: PersonalAPIKeyEdge!
token: String!
}
type UpdatePersonalAPIKeyPayload {
personalAPIKey: PersonalAPIKey
}
type RevokePersonalAPIKeyPayload {
success: Boolean!
}
type CreateOrganizationPayload {
organization: Organization
membershipEdge: MembershipEdge!
}
type UpdateOrganizationPayload {
organization: Organization
}
type DeleteOrganizationPayload {
deletedOrganizationId: ID!
}
type InviteMemberPayload {
invitationEdge: InvitationEdge!
}
type RemoveMemberPayload {
deletedMembershipId: ID!
}
type AcceptInvitationPayload {
membershipEdge: MembershipEdge!
}
type DeleteInvitationPayload {
deletedInvitationId: ID!
}
type CreateSAMLConfigurationPayload {
samlConfigurationEdge: SAMLConfigurationEdge!
}
type UpdateSAMLConfigurationPayload {
samlConfiguration: SAMLConfiguration
}
type DeleteSAMLConfigurationPayload {
deletedSamlConfigurationId: ID!
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,123 @@
// 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 connect_v1
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"go.gearno.de/kit/httpserver"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/securecookie"
)
var (
identityContextKey = &ctxKey{name: "identity"}
sessionContextKey = &ctxKey{name: "session"}
)
func SessionFromContext(ctx context.Context) *coredata.Session {
session, _ := ctx.Value(sessionContextKey).(*coredata.Session)
return session
}
func UserFromContext(ctx context.Context) *coredata.User {
user, _ := ctx.Value(identityContextKey).(*coredata.User)
return user
}
func NewSessionMiddleware(svc *iam.Service, cookieConfig securecookie.Config) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := APIKeyFromContext(ctx)
if apiKey != nil {
httpserver.RenderError(w, http.StatusBadRequest, errors.New("session authentication cannot be used with API key authentication"))
return
}
cookieValue, err := securecookie.Get(r, cookieConfig)
if err != nil {
next.ServeHTTP(w, r)
return
}
sessionID, err := gid.ParseGID(cookieValue)
if err != nil {
securecookie.Clear(w, cookieConfig)
next.ServeHTTP(w, r)
return
}
session, err := svc.SessionService.GetSession(ctx, sessionID)
if err != nil {
var errSessionNotFound *iam.ErrSessionNotFound
var errSessionExpired *iam.ErrSessionExpired
if errors.As(err, &errSessionNotFound) || errors.As(err, &errSessionExpired) {
securecookie.Clear(w, cookieConfig)
next.ServeHTTP(w, r)
return
}
panic(fmt.Errorf("cannot get session: %w", err))
}
user, err := svc.AccountService.GetIdentity(ctx, session.UserID)
if err != nil {
var errUserNotFound *iam.ErrUserNotFound
if errors.As(err, &errUserNotFound) {
securecookie.Clear(w, cookieConfig)
next.ServeHTTP(w, r)
return
}
panic(fmt.Errorf("cannot get user: %w", err))
}
userAgent := r.UserAgent()
// TODO: will work well when no layer 7 proxy is in front of the server
var ipAddress net.IP
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
ipAddress = net.ParseIP(host)
} else {
ipAddress = net.ParseIP(r.RemoteAddr)
}
err = svc.SessionService.UpdateSessionInfo(ctx, session.ID, userAgent, ipAddress)
if err != nil {
panic(fmt.Errorf("cannot update session info: %w", err))
}
ctx = context.WithValue(ctx, sessionContextKey, session)
ctx = context.WithValue(ctx, identityContextKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
err = svc.SessionService.UpdateSessionData(ctx, session.ID, session.Data)
if err != nil {
panic(fmt.Errorf("cannot update session data: %w", err))
}
},
)
}
}

View File

@@ -0,0 +1,27 @@
// 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 "go.probo.inc/probo/pkg/coredata"
func NewIdentity(identity *coredata.User) *Identity {
return &Identity{
ID: identity.ID,
Email: identity.EmailAddress,
EmailVerified: identity.EmailAddressVerified,
CreatedAt: identity.CreatedAt,
UpdatedAt: identity.UpdatedAt,
}
}

View File

@@ -21,14 +21,16 @@ import (
)
type (
InvitationOrderBy OrderBy[coredata.InvitationOrderField]
InvitationConnection struct {
TotalCount int `json:"totalCount"`
Edges []*InvitationEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
TotalCount int
Edges []*InvitationEdge
PageInfo PageInfo
Resolver any
ParentID gid.GID
Filter *InvitationFilter
Filters *coredata.InvitationFilter
}
)
@@ -36,39 +38,37 @@ func NewInvitationConnection(
p *page.Page[*coredata.Invitation, coredata.InvitationOrderField],
resolver any,
parentID gid.GID,
filter *InvitationFilter,
filters *coredata.InvitationFilter,
) *InvitationConnection {
var edges = make([]*InvitationEdge, len(p.Data))
for i := range edges {
edges[i] = NewInvitationEdge(p.Data[i], p.Cursor.OrderBy.Field)
edges := make([]*InvitationEdge, len(p.Data))
for i, invitation := range p.Data {
edges[i] = NewInvitationEdge(invitation, p.Cursor.OrderBy.Field)
}
return &InvitationConnection{
Edges: edges,
PageInfo: NewPageInfo(p),
PageInfo: *NewPageInfo(p),
Resolver: resolver,
ParentID: parentID,
Filter: filter,
Filters: filters,
}
}
func NewInvitationEdge(invitation *coredata.Invitation, orderBy coredata.InvitationOrderField) *InvitationEdge {
func NewInvitationEdge(invitation *coredata.Invitation, orderField coredata.InvitationOrderField) *InvitationEdge {
return &InvitationEdge{
Cursor: invitation.CursorKey(orderBy),
Node: NewInvitation(invitation),
Cursor: invitation.CursorKey(orderField),
}
}
func NewInvitation(i *coredata.Invitation) *Invitation {
func NewInvitation(invitation *coredata.Invitation) *Invitation {
return &Invitation{
ID: i.ID,
Email: i.Email,
FullName: i.FullName,
Role: i.Role,
Status: i.Status,
ExpiresAt: i.ExpiresAt,
AcceptedAt: i.AcceptedAt,
CreatedAt: i.CreatedAt,
ID: invitation.ID,
Email: invitation.Email,
ExpiresAt: invitation.ExpiresAt,
AcceptedAt: invitation.AcceptedAt,
CreatedAt: invitation.CreatedAt,
Status: invitation.Status,
}
}

View File

@@ -21,16 +21,16 @@ import (
)
type (
MembershipOrderBy OrderBy[coredata.MembershipOrderField]
MembershipConnection struct {
TotalCount int `json:"totalCount"`
Edges []*MembershipEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
TotalCount int
Edges []*MembershipEdge
PageInfo PageInfo
Resolver any
ParentID gid.GID
}
MembershipOrderBy OrderBy[coredata.MembershipOrderField]
)
func NewMembershipConnection(
@@ -38,36 +38,34 @@ func NewMembershipConnection(
resolver any,
parentID gid.GID,
) *MembershipConnection {
var edges = make([]*MembershipEdge, len(p.Data))
for i := range edges {
edges[i] = NewMembershipEdge(p.Data[i], p.Cursor.OrderBy.Field)
edges := make([]*MembershipEdge, len(p.Data))
for i, membership := range p.Data {
edges[i] = NewMembershipEdge(membership, p.Cursor.OrderBy.Field)
}
return &MembershipConnection{
Edges: edges,
PageInfo: NewPageInfo(p),
PageInfo: *NewPageInfo(p),
Resolver: resolver,
ParentID: parentID,
}
}
func NewMembershipEdge(membership *coredata.Membership, orderBy coredata.MembershipOrderField) *MembershipEdge {
func NewMembershipEdge(membership *coredata.Membership, orderField coredata.MembershipOrderField) *MembershipEdge {
return &MembershipEdge{
Cursor: membership.CursorKey(orderBy),
Node: NewMembership(membership),
Cursor: membership.CursorKey(orderField),
}
}
func NewMembership(m *coredata.Membership) *Membership {
func NewMembership(membership *coredata.Membership) *Membership {
return &Membership{
ID: m.ID,
UserID: m.UserID,
OrganizationID: m.OrganizationID,
Role: m.Role,
FullName: m.FullName,
EmailAddress: m.EmailAddress,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
ID: membership.ID,
CreatedAt: membership.CreatedAt,
// Permissions: membership.Permissions,
// ProvisionedBy: membership.ProvisionedBy,
// Active: membership.Active,
// LastSyncedAt: membership.LastSyncedAt,
}
}

View File

@@ -12,24 +12,13 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package mcp_v1
package types
import (
"time"
)
import "go.probo.inc/probo/pkg/page"
type (
Config struct {
Version string
RequestTimeout time.Duration
MaxRequestSize int64
OrderBy[T page.OrderField] struct {
Field T
Direction page.OrderDirection
}
)
func DefaultConfig() Config {
return Config{
Version: "1.0.0",
RequestTimeout: 30 * time.Second,
MaxRequestSize: 10 * 1024 * 1024, // 10MB
}
}

View File

@@ -0,0 +1,32 @@
// 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 (
"go.probo.inc/probo/pkg/coredata"
)
type (
OrganizationOrderBy OrderBy[coredata.OrganizationOrderField]
)
func NewOrganization(organization *coredata.Organization) *Organization {
return &Organization{
ID: organization.ID,
Name: organization.Name,
CreatedAt: organization.CreatedAt,
UpdatedAt: organization.UpdatedAt,
}
}

View File

@@ -0,0 +1,30 @@
// 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 (
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/server/gqlutils/types/pageinfo"
)
func NewPageInfo[T page.Paginable[O], O page.OrderField](p *page.Page[T, O]) *PageInfo {
data := pageinfo.NewPageInfo(p)
return &PageInfo{
HasNextPage: data.HasNextPage,
HasPreviousPage: data.HasPreviousPage,
StartCursor: data.StartCursor,
EndCursor: data.EndCursor,
}
}

Some files were not shown because too many files have changed in this diff Show More