Files
phishingclub/backend/service/option.go
T
2026-06-11 20:34:13 +02:00

602 lines
18 KiB
Go

package service
import (
"context"
"strconv"
"github.com/go-errors/errors"
"github.com/google/uuid"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/validate"
"github.com/phishingclub/phishingclub/vo"
"gorm.io/gorm"
)
// Option is a service for Option
type Option struct {
Common
OptionRepository *repository.Option
DomainRepository *repository.Domain
}
// GetOption gets an option
func (o *Option) GetOption(
ctx context.Context,
session *model.Session,
key string,
) (*model.Option, error) {
ae := NewAuditEvent("Option.GetOption", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
o.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
o.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
opt, err := o.OptionRepository.GetByKey(
ctx,
key,
)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errs.Wrap(err)
}
if err != nil {
o.Logger.Errorw("failed to get option with key",
"key", key,
"error", err,
)
return nil, errs.Wrap(err)
}
return opt, nil
}
// GetOption gets an option
func (o *Option) GetOptionWithoutAuth(
ctx context.Context,
key string,
) (*model.Option, error) {
opt, err := o.OptionRepository.GetByKey(
ctx,
key,
)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errs.Wrap(err)
}
if err != nil {
o.Logger.Errorw("failed to get option with key",
"key", key,
"error", err,
)
return nil, errs.Wrap(err)
}
return opt, nil
}
// MaskSSOSecret masks the sso secret
func (o *Option) MaskSSOSecret(opt *model.Option) (*model.Option, error) {
a, err := model.NewSSOOptionFromJSON([]byte(opt.Value.String()))
if err != nil {
o.Logger.Errorw("failed to read sso option", "error", err)
return nil, errs.Wrap(err)
}
// mask the key
a.ClientSecret = *vo.NewOptionalString1024Must("")
b, err := a.ToJSON()
if err != nil {
o.Logger.Errorw("failed to mask sso secret", "error", err)
return nil, errs.Wrap(err)
}
c, err := vo.NewOptionalString1MB(string(b))
if err != nil {
o.Logger.Errorw("failed to mask secret option", "error", err)
return nil, errs.Wrap(err)
}
opt.Value = *c
return opt, nil
}
// SetOptionByKey sets an option
func (o *Option) SetOptionByKey(
ctx context.Context,
session *model.Session,
option *model.Option,
) error {
ae := NewAuditEvent("Option.SetOptionByKey", session)
ae.Details["key"] = option.Key.String()
ae.Details["value"] = option.Value.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
o.LogAuthError(err)
return err
}
if !isAuthorized {
o.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
k := option.Key.String()
v := option.Value.String()
switch k {
case data.OptionKeyMaxFileUploadSizeMB:
if n, err := strconv.Atoi(v); err != nil || n <= 0 {
o.Logger.Debugw("invalid max file size",
"n", n,
"error", err,
)
return validate.WrapErrorWithField(
errs.NewValidationError(
errors.New("invalid max"),
),
"max file size",
)
}
case data.OptionKeyRepeatOffenderMonths:
if n, err := strconv.Atoi(v); err != nil || n <= 0 {
o.Logger.Debugw("invalid repeat offender months",
"n", n,
"error", err,
)
return validate.WrapErrorWithField(
errs.NewValidationError(
errors.New("invalid months"),
),
"repeat offender months",
)
}
case data.OptionKeyLogLevel:
// validate log level value
if v != "debug" && v != "info" && v != "warn" && v != "error" {
o.Logger.Debugw("invalid log level value",
"value", v,
)
return validate.WrapErrorWithField(
errs.NewValidationError(
errors.New("invalid log level"),
),
"log level",
)
}
case data.OptionKeyDBLogLevel:
// validate db log level value
if v != "silent" && v != "info" && v != "warn" && v != "error" {
o.Logger.Debugw("invalid db log level value",
"value", v,
)
return validate.WrapErrorWithField(
errs.NewValidationError(
errors.New("invalid db log level"),
),
"db log level",
)
}
case data.OptionKeyAdminSSOLogin:
// is allow listed
case data.OptionKeyDisplayMode:
// validate display mode value
if v != data.OptionValueDisplayModeWhitebox && v != data.OptionValueDisplayModeBlackbox {
o.Logger.Debugw("invalid display mode value",
"value", v,
)
return validate.WrapErrorWithField(
errs.NewValidationError(
errors.New("invalid display mode"),
),
"display mode",
)
}
case data.OptionKeyAutoPruneOrphanedRecipients:
// stored as JSON — validate by parsing
if _, err := model.NewAutoPruneOptionFromJSON([]byte(v)); err != nil {
o.Logger.Debugw("invalid auto-prune option value", "value", v)
return validate.WrapErrorWithField(
errs.NewValidationError(errors.New("invalid value")),
"value",
)
}
case data.OptionKeyReportPDFEnabled:
if v != "true" && v != "false" {
return validate.WrapErrorWithField(
errs.NewValidationError(errors.New("invalid value")),
"value",
)
}
case data.OptionKeyObfuscationTemplate:
// is allow listed
default:
o.Logger.Debugw("invalid settings key", "key", k)
return validate.WrapErrorWithField(
errs.NewValidationError(
errors.New("invalid option"),
),
"key",
)
}
// update options
err = o.OptionRepository.UpdateByKey(
ctx,
option,
)
if err != nil {
o.Logger.Errorw("failed to update option by key", "error", err)
return err
}
o.AuditLogAuthorized(ae)
return nil
}
// GetAutoPruneOption returns the single auto-prune option row (requires global auth).
// Both the global flag and all per-company entries are embedded in the returned value.
func (o *Option) GetAutoPruneOption(
ctx context.Context,
session *model.Session,
) (*model.AutoPruneOption, error) {
ae := NewAuditEvent("Option.GetAutoPruneOption", session)
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
o.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
o.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
o.AuditLogAuthorized(ae)
return o.getAutoPruneOption(ctx)
}
// SetAutoPruneOption persists the full auto-prune option as a single JSON row
// (requires global auth). The caller supplies the complete AutoPruneOption
// value including any per-company entries.
func (o *Option) SetAutoPruneOption(
ctx context.Context,
session *model.Session,
autoPruneOpt *model.AutoPruneOption,
) error {
ae := NewAuditEvent("Option.SetAutoPruneOption", session)
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
o.LogAuthError(err)
return errs.Wrap(err)
}
if !isAuthorized {
o.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
if err := o.upsertAutoPruneOption(ctx, autoPruneOpt); err != nil {
return err
}
o.AuditLogAuthorized(ae)
return nil
}
// GetCompanyAutoPruneOption returns whether the given company has opted in to auto-pruning
// by reading the single shared auto-prune option row (requires global auth).
func (o *Option) GetCompanyAutoPruneOption(
ctx context.Context,
session *model.Session,
companyID *uuid.UUID,
) (bool, error) {
ae := NewAuditEvent("Option.GetCompanyAutoPruneOption", session)
ae.Details["companyID"] = companyID.String()
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
o.LogAuthError(err)
return false, errs.Wrap(err)
}
if !isAuthorized {
o.AuditLogNotAuthorized(ae)
return false, errs.ErrAuthorizationFailed
}
opt, err := o.getAutoPruneOption(ctx)
if err != nil {
return false, err
}
o.AuditLogAuthorized(ae)
return opt.IsCompanyEnabled(companyID), nil
}
// SetCompanyAutoPruneOption updates the per-company enabled flag within the
// single shared auto-prune option row (requires global auth).
func (o *Option) SetCompanyAutoPruneOption(
ctx context.Context,
session *model.Session,
companyID *uuid.UUID,
enabled bool,
) error {
ae := NewAuditEvent("Option.SetCompanyAutoPruneOption", session)
ae.Details["companyID"] = companyID.String()
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
o.LogAuthError(err)
return errs.Wrap(err)
}
if !isAuthorized {
o.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
// read-modify-write the single row
opt, err := o.getAutoPruneOption(ctx)
if err != nil {
return err
}
opt.SetCompanyEnabled(companyID, enabled)
if err := o.upsertAutoPruneOption(ctx, opt); err != nil {
return err
}
o.AuditLogAuthorized(ae)
return nil
}
// GetAutoPruneOptionInternal returns the full auto-prune option without any
// authorization check. intended for internal/task-runner use only.
func (o *Option) GetAutoPruneOptionInternal(ctx context.Context) (*model.AutoPruneOption, error) {
return o.getAutoPruneOption(ctx)
}
// getAutoPruneOption reads the single auto-prune option row, returning the
// default (all-disabled) value when the row does not exist yet.
func (o *Option) getAutoPruneOption(ctx context.Context) (*model.AutoPruneOption, error) {
raw, err := o.OptionRepository.GetByKey(ctx, data.OptionKeyAutoPruneOrphanedRecipients)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.NewAutoPruneOptionDefault(), nil
}
o.Logger.Errorw("failed to get auto-prune option", "error", err)
return nil, errs.Wrap(err)
}
return model.NewAutoPruneOptionFromJSON([]byte(raw.Value.String()))
}
// upsertAutoPruneOption inserts or updates the single auto-prune option row.
func (o *Option) upsertAutoPruneOption(ctx context.Context, autoPruneOpt *model.AutoPruneOption) error {
opt, err := autoPruneOpt.ToOption()
if err != nil {
return errs.Wrap(err)
}
_, getErr := o.OptionRepository.GetByKey(ctx, data.OptionKeyAutoPruneOrphanedRecipients)
if getErr != nil {
if !errors.Is(getErr, gorm.ErrRecordNotFound) {
o.Logger.Errorw("failed to check auto-prune option existence", "error", getErr)
return errs.Wrap(getErr)
}
// row does not exist yet — insert
if _, insertErr := o.OptionRepository.Insert(ctx, opt); insertErr != nil {
o.Logger.Errorw("failed to insert auto-prune option", "error", insertErr)
return errs.Wrap(insertErr)
}
return nil
}
// row exists — update
if updateErr := o.OptionRepository.UpdateByKey(ctx, opt); updateErr != nil {
o.Logger.Errorw("failed to update auto-prune option", "error", updateErr)
return errs.Wrap(updateErr)
}
return nil
}
// GetObfuscationTemplate gets the obfuscation template from options or returns default
func (o *Option) GetObfuscationTemplate(ctx context.Context) (string, error) {
opt, err := o.OptionRepository.GetByKey(ctx, data.OptionKeyObfuscationTemplate)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// return default template if not found
return data.OptionValueObfuscationTemplateDefault, nil
}
o.Logger.Errorw("failed to get obfuscation template option", "error", err)
return "", errs.Wrap(err)
}
template := opt.Value.String()
if template == "" {
// return default if empty
return data.OptionValueObfuscationTemplateDefault, nil
}
return template, nil
}
// GetScimDomainInternal returns the configured global SCIM domain without any
// authorization check. intended for the phishing-server host gate. returns an
// empty string when SCIM serving is not configured.
func (o *Option) GetScimDomainInternal(ctx context.Context) (string, error) {
opt, err := o.OptionRepository.GetByKey(ctx, data.OptionKeyScimDomain)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil
}
o.Logger.Errorw("failed to get scim domain option", "error", err)
return "", errs.Wrap(err)
}
return opt.Value.String(), nil
}
// GetScimDomain returns the configured global SCIM domain (requires global auth).
func (o *Option) GetScimDomain(
ctx context.Context,
session *model.Session,
) (string, error) {
ae := NewAuditEvent("Option.GetScimDomain", session)
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
o.LogAuthError(err)
return "", errs.Wrap(err)
}
if !isAuthorized {
o.AuditLogNotAuthorized(ae)
return "", errs.ErrAuthorizationFailed
}
return o.GetScimDomainInternal(ctx)
}
// SetScimDomain persists the global SCIM domain (requires global auth). An empty
// string disables SCIM serving. A non-empty value must be an existing global
// domain (one not tied to a company).
func (o *Option) SetScimDomain(
ctx context.Context,
session *model.Session,
domain string,
) error {
ae := NewAuditEvent("Option.SetScimDomain", session)
ae.Details["domain"] = domain
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
o.LogAuthError(err)
return errs.Wrap(err)
}
if !isAuthorized {
o.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
// a non-empty value must resolve to an existing global domain
if domain != "" {
nameVO, err := vo.NewString255(domain)
if err != nil {
return errs.NewValidationError(err)
}
d, err := o.DomainRepository.GetByName(ctx, nameVO, &repository.DomainOption{})
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errs.NewValidationError(errors.Errorf("domain %q does not exist", domain))
}
o.Logger.Errorw("failed to look up scim domain", "error", err)
return errs.Wrap(err)
}
// only global domains (not tied to a company) may be used for SCIM
if d.CompanyID.IsSpecified() && !d.CompanyID.IsNull() {
return errs.NewValidationError(errors.Errorf("domain %q is not a global domain", domain))
}
// AiTM proxy domains route traffic to a target site and must never serve
// the SCIM provisioning API
if d.ProxyID.IsSpecified() && !d.ProxyID.IsNull() {
return errs.NewValidationError(errors.Errorf("domain %q is a proxy domain and cannot be used for SCIM", domain))
}
}
if err := o.setScimDomainValue(ctx, domain); err != nil {
return err
}
o.AuditLogAuthorized(ae)
return nil
}
// setScimDomainValue inserts or updates the single SCIM domain option row.
func (o *Option) setScimDomainValue(ctx context.Context, domain string) error {
valueVO, err := vo.NewOptionalString1MB(domain)
if err != nil {
return errs.NewValidationError(err)
}
opt := &model.Option{
Key: *vo.NewString127Must(data.OptionKeyScimDomain),
Value: *valueVO,
}
_, getErr := o.OptionRepository.GetByKey(ctx, data.OptionKeyScimDomain)
if getErr != nil {
if !errors.Is(getErr, gorm.ErrRecordNotFound) {
o.Logger.Errorw("failed to check scim domain option existence", "error", getErr)
return errs.Wrap(getErr)
}
if _, insertErr := o.OptionRepository.Insert(ctx, opt); insertErr != nil {
o.Logger.Errorw("failed to insert scim domain option", "error", insertErr)
return errs.Wrap(insertErr)
}
return nil
}
if updateErr := o.OptionRepository.UpdateByKey(ctx, opt); updateErr != nil {
o.Logger.Errorw("failed to update scim domain option", "error", updateErr)
return errs.Wrap(updateErr)
}
return nil
}
// upsertOptionValue inserts or updates a single option row by key.
func (o *Option) upsertOptionValue(ctx context.Context, key string, value string) error {
valueVO, err := vo.NewOptionalString1MB(value)
if err != nil {
return errs.NewValidationError(err)
}
opt := &model.Option{
Key: *vo.NewString127Must(key),
Value: *valueVO,
}
_, getErr := o.OptionRepository.GetByKey(ctx, key)
if getErr != nil {
if !errors.Is(getErr, gorm.ErrRecordNotFound) {
return errs.Wrap(getErr)
}
if _, insertErr := o.OptionRepository.Insert(ctx, opt); insertErr != nil {
return errs.Wrap(insertErr)
}
return nil
}
if updateErr := o.OptionRepository.UpdateByKey(ctx, opt); updateErr != nil {
return errs.Wrap(updateErr)
}
return nil
}
// GetScimSoftDeleteRetentionDaysInternal returns the retention window in days,
// defaulting when unset or invalid. No authorization check.
func (o *Option) GetScimSoftDeleteRetentionDaysInternal(ctx context.Context) (int, error) {
opt, err := o.OptionRepository.GetByKey(ctx, data.OptionKeyScimSoftDeleteRetentionDays)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return data.OptionValueScimSoftDeleteRetentionDaysDefault, nil
}
return 0, errs.Wrap(err)
}
days, convErr := strconv.Atoi(opt.Value.String())
if convErr != nil || days < 0 {
return data.OptionValueScimSoftDeleteRetentionDaysDefault, nil
}
return days, nil
}
// GetScimSoftDeleteRetentionDays returns the retention window (requires global auth).
func (o *Option) GetScimSoftDeleteRetentionDays(
ctx context.Context,
session *model.Session,
) (int, error) {
ae := NewAuditEvent("Option.GetScimSoftDeleteRetentionDays", session)
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
o.LogAuthError(err)
return 0, errs.Wrap(err)
}
if !isAuthorized {
o.AuditLogNotAuthorized(ae)
return 0, errs.ErrAuthorizationFailed
}
return o.GetScimSoftDeleteRetentionDaysInternal(ctx)
}
// SetScimSoftDeleteRetentionDays persists the retention window (requires global auth).
func (o *Option) SetScimSoftDeleteRetentionDays(
ctx context.Context,
session *model.Session,
days int,
) error {
ae := NewAuditEvent("Option.SetScimSoftDeleteRetentionDays", session)
ae.Details["days"] = days
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
o.LogAuthError(err)
return errs.Wrap(err)
}
if !isAuthorized {
o.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
if days < 0 {
return errs.NewValidationError(errors.Errorf("retention days must be zero or positive"))
}
if err := o.upsertOptionValue(ctx, data.OptionKeyScimSoftDeleteRetentionDays, strconv.Itoa(days)); err != nil {
return err
}
o.AuditLogAuthorized(ae)
return nil
}