Files
base/forms/record_upsert.go
zeekay 4e4b801d86 apis,forms,plugins: rip password cascade from consumers
- forms/RecordUpsert no longer manages a plain password / confirm /
  oldPassword triple; Load + validateFormFields only handle the
  email + verified system fields now. DrySubmit + Submit semantics
  unchanged
- apis/installer + apis/superuser_auth_oidc drop SetRandomPassword;
  the installer superuser and the OIDC auto-create path persist a
  verified passwordless record (IAM handles credentials)
- apis/collection_test snapshots updated for the slimmer auth
  collection bootstrap (no system "password" field) and the smaller
  collection inventory (12 not 16); the now-stale "create-base-
  collection-with-oldPassword-rejected" test is deleted because the
  reserved-key check is gone
- plugins/jsvm drops the PasswordField bind
- plugins/zap returns 410 Gone for /identity (the only ZAP path
  that called record.ValidatePassword) — IAM PKCE is the only login
- plugins/migratecmd test no longer probes a PasswordResetToken
  secret on auth collections
- tests/dynamic_stubs loses StubMFARecords + StubOTPRecords;
  tests/token_helper loses GetUserPasswordResetToken
2026-05-15 03:19:15 -07:00

225 lines
6.5 KiB
Go

package forms
import (
"context"
"errors"
"fmt"
"slices"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/hanzoai/dbx"
"github.com/hanzoai/base/core"
"github.com/hanzoai/base/core/validators"
"github.com/hanzoai/base/tools/security"
)
const (
accessLevelDefault = iota
accessLevelManager
accessLevelSuperuser
)
type RecordUpsert struct {
ctx context.Context
app core.App
record *core.Record
accessLevel int
}
// NewRecordUpsert creates a new [RecordUpsert] form from the provided [core.App] and [core.Record] instances
// (for create you could pass a pointer to an empty Record - core.NewRecord(collection)).
func NewRecordUpsert(app core.App, record *core.Record) *RecordUpsert {
form := &RecordUpsert{
ctx: context.Background(),
app: app,
record: record,
}
return form
}
// SetContext assigns ctx as context of the current form.
func (form *RecordUpsert) SetContext(ctx context.Context) {
form.ctx = ctx
}
// SetApp replaces the current form app instance.
//
// This could be used for example if you want to change at later stage
// before submission to change from regular -> transactional app instance.
func (form *RecordUpsert) SetApp(app core.App) {
form.app = app
}
// SetRecord replaces the current form record instance.
func (form *RecordUpsert) SetRecord(record *core.Record) {
form.record = record
}
// ResetAccess resets the form access level to the accessLevelDefault.
func (form *RecordUpsert) ResetAccess() {
form.accessLevel = accessLevelDefault
}
// GrantManagerAccess updates the form access level to "manager" allowing
// directly changing some system record fields (often used with auth collection records).
func (form *RecordUpsert) GrantManagerAccess() {
form.accessLevel = accessLevelManager
}
// GrantSuperuserAccess updates the form access level to "superuser" allowing
// directly changing all system record fields, including those marked as "Hidden".
func (form *RecordUpsert) GrantSuperuserAccess() {
form.accessLevel = accessLevelSuperuser
}
// HasManageAccess reports whether the form has "manager" or "superuser" level access.
func (form *RecordUpsert) HasManageAccess() bool {
return form.accessLevel == accessLevelManager || form.accessLevel == accessLevelSuperuser
}
// Load loads the provided data into the form and the related record.
func (form *RecordUpsert) Load(data map[string]any) {
excludeFields := []string{core.FieldNameExpand}
for k, v := range data {
if slices.Contains(excludeFields, k) {
continue
}
// set only known collection fields
field := form.record.SetIfFieldExists(k, v)
// restore original value if hidden field
//
// note: this is an extra measure against loading hidden fields
// but usually is not used by the default route handlers since
// they filter additionally the data before calling Load
if form.accessLevel != accessLevelSuperuser && field != nil && field.GetHidden() {
form.record.SetRaw(field.GetName(), form.record.Original().GetRaw(field.GetName()))
}
}
}
func (form *RecordUpsert) validateFormFields() error {
if !form.record.Collection().IsAuth() {
return nil
}
isNew := form.record.IsNew()
original := form.record.Original()
validateData := map[string]any{
"email": form.record.Email(),
"verified": form.record.Verified(),
}
return validation.Validate(validateData,
validation.Map(
validation.Key(
"email",
// don't allow direct email updates if the form doesn't have manage access permissions
// (aka. allow only admin or authorized auth models to directly update the field)
validation.When(
!isNew && !form.HasManageAccess(),
validation.By(validators.Equal(original.Email())),
),
),
validation.Key(
"verified",
// don't allow changing verified if the form doesn't have manage access permissions
// (aka. allow only admin or authorized auth models to directly change the field)
validation.When(
!form.HasManageAccess(),
validation.By(validators.Equal(original.Verified())),
),
),
),
)
}
// Deprecated: It was previously used as part of the record create action but it is not needed anymore and will be removed in the future.
//
// DrySubmit performs a temp form submit within a transaction and reverts it at the end.
// For actual record persistence, check the [RecordUpsert.Submit()] method.
//
// This method doesn't perform validations, handle file uploads/deletes or trigger app save events!
func (form *RecordUpsert) DrySubmit(callback func(txApp core.App, drySavedRecord *core.Record) error) error {
isNew := form.record.IsNew()
clone := form.record.Clone()
// set an id if it doesn't have already
// (the value doesn't matter; it is used only during the manual delete/update rollback)
if clone.IsNew() && clone.Id == "" {
clone.Id = "_temp_" + security.PseudorandomString(15)
}
app := form.app.UnsafeWithoutHooks()
isTransactional := app.IsTransactional()
if !isTransactional {
return app.RunInTransaction(func(txApp core.App) error {
tx, ok := txApp.DB().(*dbx.Tx)
if !ok {
return errors.New("failed to get transaction db")
}
defer tx.Rollback()
if err := txApp.SaveNoValidateWithContext(form.ctx, clone); err != nil {
return validators.NormalizeUniqueIndexError(err, clone.Collection().Name, clone.Collection().Fields.FieldNames())
}
if callback != nil {
return callback(txApp, clone)
}
return nil
})
}
// already in a transaction
// (manual rollback to avoid starting another transaction)
// ---------------------------------------------------------------
err := app.SaveNoValidateWithContext(form.ctx, clone)
if err != nil {
return validators.NormalizeUniqueIndexError(err, clone.Collection().Name, clone.Collection().Fields.FieldNames())
}
manualRollback := func() error {
if isNew {
err = app.DeleteWithContext(form.ctx, clone)
if err != nil {
return fmt.Errorf("failed to rollback dry submit created record: %w", err)
}
} else {
clone.Load(clone.Original().FieldsData())
err = app.SaveNoValidateWithContext(form.ctx, clone)
if err != nil {
return fmt.Errorf("failed to rollback dry submit updated record: %w", err)
}
}
return nil
}
if callback != nil {
return errors.Join(callback(app, clone), manualRollback())
}
return manualRollback()
}
// Submit validates the form specific validations and attempts to save the form record.
func (form *RecordUpsert) Submit() error {
err := form.validateFormFields()
if err != nil {
return err
}
// run record validations and persist in db
return form.app.SaveWithContext(form.ctx, form.record)
}