Rewrite identity and access management
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
1687
pkg/auth/service.go
1687
pkg/auth/service.go
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
120
pkg/bearertoken/bearertoken.go
Normal file
120
pkg/bearertoken/bearertoken.go
Normal 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 == '/'
|
||||
}
|
||||
298
pkg/bearertoken/bearertoken_test.go
Normal file
298
pkg/bearertoken/bearertoken_test.go
Normal 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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
11
pkg/coredata/errors.go
Normal 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")
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
11
pkg/coredata/expire_reason.go
Normal file
11
pkg/coredata/expire_reason.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package coredata
|
||||
|
||||
type (
|
||||
ExpireReason string
|
||||
)
|
||||
|
||||
const (
|
||||
ExpireReasonIdleTimeout ExpireReason = "idle_timeout"
|
||||
ExpireReasonRevoked ExpireReason = "revoked"
|
||||
ExpireReasonClosed ExpireReason = "closed"
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 := `
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
9
pkg/coredata/migrations/20251205T120000Z.sql
Normal file
9
pkg/coredata/migrations/20251205T120000Z.sql
Normal 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();
|
||||
2
pkg/coredata/migrations/20251205T140000Z.sql
Normal file
2
pkg/coredata/migrations/20251205T140000Z.sql
Normal 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';
|
||||
8
pkg/coredata/migrations/20251205T150000Z.sql
Normal file
8
pkg/coredata/migrations/20251205T150000Z.sql
Normal 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)
|
||||
);
|
||||
|
||||
2
pkg/coredata/migrations/20251212T120000Z.sql
Normal file
2
pkg/coredata/migrations/20251212T120000Z.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE auth_user_api_keys ADD COLUMN expire_reason TEXT;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
40
pkg/coredata/saml_configuration_order_field.go
Normal file
40
pkg/coredata/saml_configuration_order_field.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
40
pkg/coredata/user_api_key_order_field.go
Normal file
40
pkg/coredata/user_api_key_order_field.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
204
pkg/iam/access_management_service.go
Normal file
204
pkg/iam/access_management_service.go
Normal 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
748
pkg/iam/account_service.go
Normal 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
|
||||
// }
|
||||
41
pkg/iam/action_registry.go
Normal file
41
pkg/iam/action_registry.go
Normal 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
78
pkg/iam/api_key.go
Normal 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
488
pkg/iam/auth_service.go
Normal 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
289
pkg/iam/errors.go
Normal 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
|
||||
}
|
||||
1106
pkg/iam/organization_service.go
Normal file
1106
pkg/iam/organization_service.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 (
|
||||
@@ -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
90
pkg/iam/saml/errors.go
Normal 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
97
pkg/iam/saml/gc.go
Normal 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
|
||||
},
|
||||
)
|
||||
}
|
||||
87
pkg/iam/saml/saml_metadata.go
Normal file
87
pkg/iam/saml/saml_metadata.go
Normal 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
402
pkg/iam/saml/service.go
Normal 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
68
pkg/iam/saml/sp.go
Normal 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
|
||||
}
|
||||
@@ -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
194
pkg/iam/service.go
Normal 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
266
pkg/iam/session_service.go
Normal 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
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
91
pkg/securetoken/securetoken.go
Normal file
91
pkg/securetoken/securetoken.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
94
pkg/server/api/connect/v1/api_key_middleware.go
Normal file
94
pkg/server/api/connect/v1/api_key_middleware.go
Normal 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))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
19
pkg/server/api/connect/v1/ctxkey.go
Normal file
19
pkg/server/api/connect/v1/ctxkey.go
Normal 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 }
|
||||
)
|
||||
36
pkg/server/api/connect/v1/gqlgen.yaml
Normal file
36
pkg/server/api/connect/v1/gqlgen.yaml
Normal 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"
|
||||
102
pkg/server/api/connect/v1/graphql_handler.go
Normal file
102
pkg/server/api/connect/v1/graphql_handler.go
Normal 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
|
||||
}
|
||||
51
pkg/server/api/connect/v1/httpctx.go
Normal file
51
pkg/server/api/connect/v1/httpctx.go
Normal 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)
|
||||
}
|
||||
66
pkg/server/api/connect/v1/resolver.go
Normal file
66
pkg/server/api/connect/v1/resolver.go
Normal 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
|
||||
}
|
||||
96
pkg/server/api/connect/v1/saml_handler.go
Normal file
96
pkg/server/api/connect/v1/saml_handler.go
Normal 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)
|
||||
}
|
||||
769
pkg/server/api/connect/v1/schema.graphql
Normal file
769
pkg/server/api/connect/v1/schema.graphql
Normal 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!
|
||||
}
|
||||
20067
pkg/server/api/connect/v1/schema/schema.go
Normal file
20067
pkg/server/api/connect/v1/schema/schema.go
Normal file
File diff suppressed because it is too large
Load Diff
123
pkg/server/api/connect/v1/session_middleware.go
Normal file
123
pkg/server/api/connect/v1/session_middleware.go
Normal 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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
27
pkg/server/api/connect/v1/types/identity.go
Normal file
27
pkg/server/api/connect/v1/types/identity.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
32
pkg/server/api/connect/v1/types/organization.go
Normal file
32
pkg/server/api/connect/v1/types/organization.go
Normal 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,
|
||||
}
|
||||
}
|
||||
30
pkg/server/api/connect/v1/types/page_info.go
Normal file
30
pkg/server/api/connect/v1/types/page_info.go
Normal 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
Reference in New Issue
Block a user