init push

This commit is contained in:
2026-07-16 22:16:45 +07:00
commit 8b068bdb10
1021 changed files with 332816 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
package auth
const (
RoleCodePilot = "pilot"
RoleCodeDoctor = "doctor"
RoleCodeAirRescuer = "air_rescuer"
RoleCodeTechnician = "technician"
RoleCodeFlightAssistant = "flight_assistant"
RoleCodeStaff = "staff"
)
const (
PilotCategoryRegular = "regular"
PilotCategoryFreelance = "freelance"
PilotCategoryDryLease = "dry_lease"
)

View File

@@ -0,0 +1,141 @@
package auth
import "time"
// PilotProfile stores role-specific data for role=pilot.
type PilotProfile struct {
UserID []byte `gorm:"type:binary(16);primaryKey;column:user_id"`
PilotCategory string `gorm:"type:varchar(16);not null;index;column:pilot_category"`
ShortName string `gorm:"type:varchar(120);column:short_name"`
LicenseNo string `gorm:"type:varchar(120);index;column:license_no"`
LicenseIssuedAt *time.Time `gorm:"column:license_issued_at"`
LicenseExpiredAt *time.Time `gorm:"column:license_expired_at"`
TechnicianLicenseNo string `gorm:"type:varchar(120);column:technician_license_no"`
HasIFRQualification bool `gorm:"not null;default:false;column:has_ifr_qualification"`
IsChiefPilot bool `gorm:"not null;default:false;column:is_chief_pilot"`
IsDUL bool `gorm:"not null;default:false;column:is_dul"`
TotalFlightMinutes int `gorm:"not null;default:0;column:total_flight_minutes"`
ResponsiblePilotMinutes int `gorm:"not null;default:0;column:responsible_pilot_minutes"`
SecondPilotMinutes int `gorm:"not null;default:0;column:second_pilot_minutes"`
DoubleCommandMinutes int `gorm:"not null;default:0;column:double_command_minutes"`
FlightInstructorMinutes int `gorm:"not null;default:0;column:flight_instructor_minutes"`
NightFlightMinutes int `gorm:"not null;default:0;column:night_flight_minutes"`
IFRFlightMinutes int `gorm:"not null;default:0;column:ifr_flight_minutes"`
Location string `gorm:"type:varchar(255);column:location"`
Postcode string `gorm:"type:varchar(32);column:postcode"`
StreetLine string `gorm:"type:varchar(255);column:street_line"`
Note string `gorm:"type:text;column:note"`
WeightKG *float64 `gorm:"type:decimal(8,2);column:weight_kg"`
PhotoFileID string `gorm:"type:varchar(255);column:photo_file_id"`
CreatedAt time.Time
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
User User `gorm:"foreignKey:UserID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
}
func (PilotProfile) TableName() string { return "pilot_profiles" }
// DoctorProfile stores role-specific data for role=doctor.
type DoctorProfile struct {
UserID []byte `gorm:"type:binary(16);primaryKey;column:user_id"`
ShortName string `gorm:"type:varchar(120);column:short_name"`
Specialization string `gorm:"type:varchar(120);column:specialization"`
SubSpecialization string `gorm:"type:varchar(120);column:sub_specialization"`
IsChiefPhysician bool `gorm:"not null;default:false;column:is_chief_physician"`
Location string `gorm:"type:varchar(255);column:location"`
Postcode string `gorm:"type:varchar(32);column:postcode"`
StreetLine string `gorm:"type:varchar(255);column:street_line"`
Note string `gorm:"type:text;column:note"`
PhotoFileID string `gorm:"type:varchar(255);column:photo_file_id"`
CreatedAt time.Time
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
User User `gorm:"foreignKey:UserID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
}
func (DoctorProfile) TableName() string { return "doctor_profiles" }
// AirRescuerProfile stores role-specific data for role=air_rescuer.
type AirRescuerProfile struct {
UserID []byte `gorm:"type:binary(16);primaryKey;column:user_id"`
ShortName string `gorm:"type:varchar(120);column:short_name"`
ResponsibleFlightRescuer bool `gorm:"not null;default:false;column:responsible_flight_rescuer"`
Location string `gorm:"type:varchar(255);column:location"`
Postcode string `gorm:"type:varchar(32);column:postcode"`
StreetLine string `gorm:"type:varchar(255);column:street_line"`
Note string `gorm:"type:text;column:note"`
PhotoFileID string `gorm:"type:varchar(255);column:photo_file_id"`
CreatedAt time.Time
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
User User `gorm:"foreignKey:UserID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
}
func (AirRescuerProfile) TableName() string { return "air_rescuer_profiles" }
// TechnicianProfile stores role-specific data for role=technician.
type TechnicianProfile struct {
UserID []byte `gorm:"type:binary(16);primaryKey;column:user_id"`
ShortName string `gorm:"type:varchar(120);column:short_name"`
LicenseNo string `gorm:"type:varchar(120);index;column:license_no"`
IsChiefTechnician bool `gorm:"not null;default:false;column:is_chief_technician"`
IsResponsibleComplaints bool `gorm:"not null;default:false;column:is_responsible_complaints"`
Location string `gorm:"type:varchar(255);column:location"`
Postcode string `gorm:"type:varchar(32);column:postcode"`
StreetLine string `gorm:"type:varchar(255);column:street_line"`
Note string `gorm:"type:text;column:note"`
PhotoFileID string `gorm:"type:varchar(255);column:photo_file_id"`
CreatedAt time.Time
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
User User `gorm:"foreignKey:UserID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
}
func (TechnicianProfile) TableName() string { return "technician_profiles" }
// FlightAssistantProfile stores role-specific data for role=flight_assistant.
type FlightAssistantProfile struct {
UserID []byte `gorm:"type:binary(16);primaryKey;column:user_id"`
ShortName string `gorm:"type:varchar(120);column:short_name"`
Location string `gorm:"type:varchar(255);column:location"`
Postcode string `gorm:"type:varchar(32);column:postcode"`
StreetLine string `gorm:"type:varchar(255);column:street_line"`
Note string `gorm:"type:text;column:note"`
PhotoFileID string `gorm:"type:varchar(255);column:photo_file_id"`
CreatedAt time.Time
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
User User `gorm:"foreignKey:UserID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
}
func (FlightAssistantProfile) TableName() string { return "flight_assistant_profiles" }
// StaffProfile stores role-specific data for role=staff.
type StaffProfile struct {
UserID []byte `gorm:"type:binary(16);primaryKey;column:user_id"`
ShortName string `gorm:"type:varchar(120);column:short_name"`
RoleLabel string `gorm:"type:varchar(120);column:role_label"`
Location string `gorm:"type:varchar(255);column:location"`
Postcode string `gorm:"type:varchar(32);column:postcode"`
StreetLine string `gorm:"type:varchar(255);column:street_line"`
Note string `gorm:"type:text;column:note"`
PhotoFileID string `gorm:"type:varchar(255);column:photo_file_id"`
CreatedAt time.Time
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
User User `gorm:"foreignKey:UserID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
}
func (StaffProfile) TableName() string { return "staff_profiles" }

View File

@@ -0,0 +1,34 @@
package auth
import (
"time"
"gorm.io/gorm"
"wucher/internal/shared/pkg/uuidv7"
)
type UserEmailLoginOTP struct {
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
UserID []byte `gorm:"type:binary(16);index;not null;column:user_id"`
OTPHash []byte `gorm:"type:binary(32);not null;column:otp_hash"`
ExpiresAt time.Time `gorm:"index;not null;column:expires_at"`
UsedAt *time.Time `gorm:"index;column:used_at"`
AttemptCount int `gorm:"not null;default:0;column:attempt_count"`
MaxAttempts int `gorm:"not null;default:5;column:max_attempts"`
ResendCount int `gorm:"not null;default:1;column:resend_count"`
LastSentAt time.Time `gorm:"index;not null;column:last_sent_at"`
CreatedAt time.Time
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
}
func (UserEmailLoginOTP) TableName() string { return "user_email_login_otps" }
func (o *UserEmailLoginOTP) BeforeCreate(_ *gorm.DB) error {
if len(o.ID) == 0 {
o.ID = uuidv7.MustBytes()
}
return nil
}

View File

@@ -0,0 +1,143 @@
package auth
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"gorm.io/gorm"
"wucher/internal/shared/pkg/uuidv7"
"wucher/internal/queue"
)
const (
EmailOutboxStatusPending = "pending"
EmailOutboxStatusProcessing = "processing"
EmailOutboxStatusPublished = "published"
EmailOutboxStatusDead = "dead"
)
type EmailOutboxMessage struct {
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
MessageID string `gorm:"type:varchar(64);uniqueIndex;not null;column:message_id"`
Kind string `gorm:"type:varchar(64);index;not null;column:kind"`
JobType string `gorm:"type:varchar(64);index;not null;column:job_type"`
Body []byte `gorm:"type:longblob;not null;column:body"`
Attributes []byte `gorm:"type:longblob;column:attributes"`
MessageGroupID string `gorm:"type:varchar(128);column:message_group_id"`
DeduplicationID string `gorm:"type:varchar(128);column:deduplication_id"`
Status string `gorm:"type:varchar(32);index;not null;column:status"`
Attempts int `gorm:"not null;default:0;column:attempts"`
AvailableAt time.Time `gorm:"index;not null;column:available_at"`
LockedAt *time.Time `gorm:"index;column:locked_at"`
LockedBy string `gorm:"type:varchar(64);column:locked_by"`
PublishedAt *time.Time `gorm:"column:published_at"`
LastError string `gorm:"type:text;column:last_error"`
CorrelationID string `gorm:"type:varchar(64);index;column:correlation_id"`
TraceID string `gorm:"type:varchar(64);index;column:trace_id"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (EmailOutboxMessage) TableName() string { return "email_outbox_messages" }
func (m *EmailOutboxMessage) BeforeCreate(_ *gorm.DB) error {
if len(m.ID) == 0 {
m.ID = uuidv7.MustBytes()
}
if strings.TrimSpace(m.Status) == "" {
m.Status = EmailOutboxStatusPending
}
if m.AvailableAt.IsZero() {
m.AvailableAt = time.Now().UTC()
}
return nil
}
func NewEmailOutboxMessage(now time.Time, message *queue.OutboundMessage) (*EmailOutboxMessage, error) {
if message == nil {
return nil, errors.New("outbound message is required")
}
if strings.TrimSpace(message.ID) == "" {
return nil, errors.New("outbound message id is required")
}
if len(message.Body) == 0 {
return nil, errors.New("outbound message body is required")
}
attributes, err := json.Marshal(message.Attributes)
if err != nil {
return nil, err
}
if now.IsZero() {
now = time.Now().UTC()
}
kind := strings.TrimSpace(message.Attributes["message_kind"])
if kind == "" {
kind = queue.EmailKind
}
jobType := strings.TrimSpace(message.Attributes["job_type"])
if jobType == "" {
jobType = "generic"
}
return &EmailOutboxMessage{
MessageID: strings.TrimSpace(message.ID),
Kind: kind,
JobType: jobType,
Body: append([]byte(nil), message.Body...),
Attributes: append([]byte(nil), attributes...),
MessageGroupID: strings.TrimSpace(message.MessageGroupID),
DeduplicationID: strings.TrimSpace(message.DeduplicationID),
Status: EmailOutboxStatusPending,
AvailableAt: now.UTC(),
CorrelationID: strings.TrimSpace(message.Attributes["correlation_id"]),
TraceID: strings.TrimSpace(message.Attributes["trace_id"]),
}, nil
}
func (m *EmailOutboxMessage) OutboundMessage() (*queue.OutboundMessage, error) {
if m == nil {
return nil, errors.New("email outbox message is required")
}
attributes := map[string]string{}
if len(m.Attributes) > 0 {
if err := json.Unmarshal(m.Attributes, &attributes); err != nil {
return nil, err
}
}
if strings.TrimSpace(m.MessageID) == "" {
return nil, errors.New("message id is required")
}
if len(m.Body) == 0 {
return nil, errors.New("message body is required")
}
return &queue.OutboundMessage{
ID: m.MessageID,
Body: append([]byte(nil), m.Body...),
Attributes: attributes,
MessageGroupID: strings.TrimSpace(m.MessageGroupID),
DeduplicationID: strings.TrimSpace(m.DeduplicationID),
}, nil
}
type EmailOutboxWriter interface {
CreateEmailOutboxMessage(ctx context.Context, message *EmailOutboxMessage) error
}
type EmailOutboxRepository interface {
EmailOutboxWriter
ClaimPendingEmailOutboxMessages(ctx context.Context, limit int, now time.Time, lockTTL time.Duration, workerID string) ([]EmailOutboxMessage, error)
MarkEmailOutboxMessagePublished(ctx context.Context, id []byte, publishedAt time.Time) error
MarkEmailOutboxMessageRetry(ctx context.Context, id []byte, availableAt time.Time, lastErr string) error
MarkEmailOutboxMessageDead(ctx context.Context, id []byte, failedAt time.Time, lastErr string) error
CountPendingEmailOutboxMessages(ctx context.Context, now time.Time) (int64, error)
}
type TransactionalRepository interface {
WithTransaction(ctx context.Context, fn func(repo Repository, outbox EmailOutboxWriter) error) error
}

View File

@@ -0,0 +1,31 @@
package auth
import (
"time"
"gorm.io/gorm"
"wucher/internal/shared/pkg/uuidv7"
)
type UserIdentity struct {
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
UserID []byte `gorm:"type:binary(16);uniqueIndex:ux_user_identity_user;not null;column:user_id"`
Provider string `gorm:"type:varchar(32);uniqueIndex:ux_provider_subject;not null;column:provider"`
ProviderSubject string `gorm:"type:varchar(191);uniqueIndex:ux_provider_subject;not null;column:provider_subject"`
Email string `gorm:"type:varchar(191);index;column:email"`
Metadata []byte `gorm:"type:json;column:metadata"`
CreatedAt time.Time
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
}
func (UserIdentity) TableName() string { return "user_identities" }
func (ui *UserIdentity) BeforeCreate(tx *gorm.DB) error {
if len(ui.ID) == 0 {
ui.ID = uuidv7.MustBytes()
}
return nil
}

View File

@@ -0,0 +1,40 @@
package auth
import (
"time"
"gorm.io/gorm"
"wucher/internal/shared/pkg/uuidv7"
)
type UserMicrosoftOAuthToken struct {
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
UserID []byte `gorm:"type:binary(16);uniqueIndex:ux_user_ms_oauth_user_provider;not null;column:user_id"`
Provider string `gorm:"type:varchar(32);uniqueIndex:ux_user_ms_oauth_user_provider;not null;column:provider"`
TenantID string `gorm:"type:varchar(128);index;not null;column:tenant_id"`
MicrosoftObjectID string `gorm:"type:varchar(191);not null;column:microsoft_object_id"`
MicrosoftSubject string `gorm:"type:varchar(191);not null;column:microsoft_subject"`
MicrosoftSessionID string `gorm:"type:varchar(191);column:microsoft_session_id"`
RefreshTokenEncrypted []byte `gorm:"type:blob;not null;column:refresh_token_encrypted"`
AccessTokenEncrypted []byte `gorm:"type:blob;column:access_token_encrypted"`
IDTokenEncrypted []byte `gorm:"type:blob;column:id_token_encrypted"`
Scope string `gorm:"type:text;column:scope"`
TokenType string `gorm:"type:varchar(32);column:token_type"`
AccessTokenExpiresAt *time.Time `gorm:"column:access_token_expires_at"`
RefreshTokenExpiresAt *time.Time `gorm:"column:refresh_token_expires_at"`
RefreshTokenRotatedAt *time.Time `gorm:"column:refresh_token_rotated_at"`
CreatedAt time.Time
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
}
func (UserMicrosoftOAuthToken) TableName() string { return "user_microsoft_oauth_tokens" }
func (t *UserMicrosoftOAuthToken) BeforeCreate(tx *gorm.DB) error {
if len(t.ID) == 0 {
t.ID = uuidv7.MustBytes()
}
return nil
}

View File

@@ -0,0 +1,32 @@
package auth
import (
"time"
"gorm.io/gorm"
"wucher/internal/shared/pkg/uuidv7"
)
type PasswordResetToken struct {
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
UserID []byte `gorm:"type:binary(16);index;not null;column:user_id"`
TokenHash []byte `gorm:"type:varbinary(32);uniqueIndex;not null;column:token_hash"`
ExpiresAt time.Time `gorm:"index;not null;column:expires_at"`
UsedAt *time.Time `gorm:"index;column:used_at"`
RequestedIP string `gorm:"type:varchar(128);column:requested_ip"`
UserAgent string `gorm:"type:varchar(255);column:user_agent"`
CreatedAt time.Time
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
}
func (PasswordResetToken) TableName() string { return "password_reset_tokens" }
func (prt *PasswordResetToken) BeforeCreate(tx *gorm.DB) error {
if len(prt.ID) == 0 {
prt.ID = uuidv7.MustBytes()
}
return nil
}

View File

@@ -0,0 +1,32 @@
package auth
import (
"time"
"gorm.io/gorm"
"wucher/internal/shared/pkg/uuidv7"
)
type Permission struct {
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
Name string `gorm:"type:varchar(64);uniqueIndex;not null;column:name"`
Key string `gorm:"type:varchar(64);uniqueIndex;not null;column:key"`
Description string `gorm:"type:varchar(255);column:description"`
Module string `gorm:"type:varchar(64);index;column:module"`
RequiresPIN bool `gorm:"not null;default:false;column:requires_pin"`
CreatedAt time.Time
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
}
func (Permission) TableName() string { return "permissions" }
func (p *Permission) BeforeCreate(tx *gorm.DB) error {
if len(p.ID) == 0 {
p.ID = uuidv7.MustBytes()
}
return nil
}

View File

@@ -0,0 +1,129 @@
package auth
import "strings"
const (
ModuleUser = "user"
ModuleRole = "role"
ModuleHelicopter = "helicopter"
ModuleHelicopterUsage = "helicopter_usage"
ModuleReserveAC = "reserve_ac"
ModuleComplaint = "complaint"
ModuleActionSignoff = "action_signoff"
ModuleMCF = "mcf"
ModuleEASARelease = "easa_release"
ModuleFlightInspection = "flight_inspection"
ModuleAfterFlight = "after_flight"
ModuleFlight = "flight"
ModuleFlightPrepCheck = "flight_prep_check"
ModuleDutyRoster = "duty_roster"
ModuleDUL = "dul"
ModuleHEMSOperation = "hems_operation"
ModuleHEMSBase = "hems_base"
ModuleOperation = "operation"
ModuleOperationCategory = "operation_category"
ModuleForcesPresent = "forces_present"
ModuleAirRescuerChecklist = "air_rescuer_checklist"
ModuleMedicine = "medicine"
ModulePatientData = "patient_data"
ModuleInsurancePatientData = "insurance_patient_data"
ModuleHealthInsuranceCompanies = "health_insurance_companies"
ModuleVocation = "vocation"
ModuleBase = "base"
ModuleFacility = "facility"
ModuleCountry = "country"
ModuleFederalState = "federal_state"
ModuleICAO = "icao"
ModuleNoICAOCode = "no_icao_code"
ModuleOPC = "opc"
ModuleMasterSettings = "master_settings"
ModuleFileManager = "file_manager"
ModuleFilesystem = "filesystem"
ModuleAudit = "audit"
)
type ModuleInfo struct {
Key string
Label string
}
var ModuleRegistry = []ModuleInfo{
{ModuleUser, "User"},
{ModuleRole, "Role"},
{ModuleHelicopter, "Helicopter"},
{ModuleHelicopterUsage, "Helicopter Usage"},
{ModuleReserveAC, "Reserve AC"},
{ModuleComplaint, "Complaint"},
{ModuleActionSignoff, "Action Sign-off"},
{ModuleMCF, "MCF"},
{ModuleEASARelease, "EASA Release"},
{ModuleFlightInspection, "Flight Inspection"},
{ModuleAfterFlight, "After Flight Inspection"},
{ModuleFlight, "Flight"},
{ModuleFlightPrepCheck, "Flight Prep Check"},
{ModuleDutyRoster, "Duty Roster"},
{ModuleDUL, "DUL"},
{ModuleHEMSOperation, "HEMS Operation"},
{ModuleHEMSBase, "HEMS Base"},
{ModuleOperation, "Operation"},
{ModuleOperationCategory, "Operation Category"},
{ModuleForcesPresent, "Forces Present"},
{ModuleAirRescuerChecklist, "Air Rescuer Checklist"},
{ModuleMedicine, "Medicine"},
{ModulePatientData, "Patient Data"},
{ModuleInsurancePatientData, "Insurance Patient Data"},
{ModuleHealthInsuranceCompanies, "Health Insurance Companies"},
{ModuleVocation, "Vocation"},
{ModuleBase, "Base"},
{ModuleFacility, "Facility"},
{ModuleCountry, "Country"},
{ModuleFederalState, "Federal State"},
{ModuleICAO, "ICAO"},
{ModuleNoICAOCode, "No ICAO Code"},
{ModuleOPC, "OPC"},
{ModuleMasterSettings, "Master Settings"},
{ModuleFileManager, "File Manager"},
{ModuleFilesystem, "Filesystem"},
{ModuleAudit, "Audit"},
}
var moduleOrder = func() map[string]int {
m := make(map[string]int, len(ModuleRegistry))
for i := range ModuleRegistry {
m[ModuleRegistry[i].Key] = i
}
return m
}()
// ModuleLabel returns the display label for a module key, falling back to the
// key itself when the module is not registered.
func ModuleLabel(key string) string {
if i, ok := moduleOrder[key]; ok {
return ModuleRegistry[i].Label
}
return key
}
// IsRegisteredModule reports whether key is a known module in ModuleRegistry.
func IsRegisteredModule(key string) bool {
_, ok := moduleOrder[key]
return ok
}
// ModuleSortIndex returns a module's position in ModuleRegistry, or a large
// value (sorting unknown modules last) when it is not registered.
func ModuleSortIndex(key string) int {
if idx, ok := moduleOrder[key]; ok {
return idx
}
return len(ModuleRegistry)
}
// ModuleForKey derives the module from a permission key by taking the segment
// before the first dot, e.g. "dul.create" -> "dul", "role.permission.read" -> "role".
func ModuleForKey(permissionKey string) string {
if i := strings.Index(permissionKey, "."); i >= 0 {
return permissionKey[:i]
}
return permissionKey
}

View File

@@ -0,0 +1,49 @@
package auth
import "testing"
func TestModuleRegistryIsConsistent(t *testing.T) {
seen := make(map[string]bool, len(ModuleRegistry))
for _, m := range ModuleRegistry {
if m.Key == "" {
t.Errorf("registry has an entry with empty key (label %q)", m.Label)
}
if m.Label == "" {
t.Errorf("module %q has an empty label", m.Key)
}
if seen[m.Key] {
t.Errorf("duplicate module key %q in ModuleRegistry", m.Key)
}
seen[m.Key] = true
}
}
func TestModuleForKey(t *testing.T) {
cases := map[string]string{
"dul.create": "dul",
"role.permission.read": "role",
"file_manager.file.read": "file_manager",
"audit.read": "audit",
"filesystem": "filesystem",
}
for in, want := range cases {
if got := ModuleForKey(in); got != want {
t.Errorf("ModuleForKey(%q) = %q, want %q", in, got, want)
}
}
}
func TestModuleLabelAndRegistration(t *testing.T) {
if got := ModuleLabel(ModuleDUL); got != "DUL" {
t.Errorf("ModuleLabel(%q) = %q, want %q", ModuleDUL, got, "DUL")
}
if got := ModuleLabel("unknown_module"); got != "unknown_module" {
t.Errorf("ModuleLabel fallback = %q, want the key itself", got)
}
if !IsRegisteredModule(ModuleComplaint) {
t.Errorf("expected %q to be a registered module", ModuleComplaint)
}
if IsRegisteredModule("unknown_module") {
t.Errorf("did not expect %q to be registered", "unknown_module")
}
}

View File

@@ -0,0 +1,24 @@
package auth
import "strings"
var defaultPINRequiredPermissionKeys = []string{
"audit.read",
"easa_release.sign",
}
func DefaultPINRequiredPermissionKeys() []string {
out := make([]string, len(defaultPINRequiredPermissionKeys))
copy(out, defaultPINRequiredPermissionKeys)
return out
}
func DefaultPermissionRequiresPIN(key string) bool {
key = strings.ToLower(strings.TrimSpace(key))
for i := range defaultPINRequiredPermissionKeys {
if defaultPINRequiredPermissionKeys[i] == key {
return true
}
}
return false
}

View File

@@ -0,0 +1,57 @@
package auth
import (
"context"
"time"
)
type Repository interface {
// Users
CreateUser(ctx context.Context, user *User) error
GetUserByID(ctx context.Context, id []byte) (*User, error)
GetUserByEmail(ctx context.Context, email string) (*User, error)
GetUserBySSOEmail(ctx context.Context, ssoEmail string) (*User, error)
GetUserByUsername(ctx context.Context, username string) (*User, error)
UpdateUserTimezone(ctx context.Context, id []byte, timezone string) error
SetUserPassword(ctx context.Context, id []byte, passwordHash, salt []byte) error
SetUserSecurityPIN(ctx context.Context, id []byte, pinHash []byte, setAt time.Time) error
MarkEmailVerified(ctx context.Context, id []byte) error
// Password resets
CreatePasswordResetToken(ctx context.Context, token *PasswordResetToken) error
InvalidateActivePasswordResetTokensByUserID(ctx context.Context, userID []byte, usedAt time.Time) error
ConsumePasswordResetTokenAndSetPassword(ctx context.Context, tokenHash, passwordHash, salt []byte, usedAt time.Time) ([]byte, bool, error)
// Security PIN resets
CreateSecurityPINResetToken(ctx context.Context, token *SecurityPINResetToken) error
InvalidateActiveSecurityPINResetTokensByUserID(ctx context.Context, userID []byte, usedAt time.Time) error
GetActiveSecurityPINResetTokenUser(ctx context.Context, tokenHash []byte, now time.Time) (*User, error)
ConsumeSecurityPINResetTokenAndSetPIN(ctx context.Context, tokenHash, pinHash []byte, usedAt time.Time) ([]byte, bool, error)
// Identities (SSO)
UpsertIdentity(ctx context.Context, identity *UserIdentity) error
CreateIdentity(ctx context.Context, identity *UserIdentity) error
GetIdentityByProviderSubject(ctx context.Context, provider, subject string) (*UserIdentity, error)
GetIdentityByUserID(ctx context.Context, userID []byte) (*UserIdentity, error)
DeleteIdentityByUserIDProvider(ctx context.Context, userID []byte, provider string) (bool, error)
// TOTP
GetUserTOTP(ctx context.Context, userID []byte) (*UserTOTP, error)
UpsertUserTOTP(ctx context.Context, totp *UserTOTP) error
DisableUserTOTP(ctx context.Context, userID []byte) error
DeleteUserTOTPSetup(ctx context.Context, userID []byte) error
UpdateUserTOTPLastUsed(ctx context.Context, userID []byte) error
// Email login OTP
GetActiveUserEmailLoginOTP(ctx context.Context, userID []byte, now time.Time) (*UserEmailLoginOTP, error)
CountUserEmailLoginOTPSince(ctx context.Context, userID []byte, since time.Time) (int64, error)
CreateUserEmailLoginOTP(ctx context.Context, otp *UserEmailLoginOTP) error
InvalidateActiveUserEmailLoginOTPs(ctx context.Context, userID []byte, usedAt time.Time) error
ConsumeUserEmailLoginOTP(ctx context.Context, userID, otpHash []byte, now time.Time) (bool, int, error)
// WebAuthn
ListUserWebAuthnCredentials(ctx context.Context, userID []byte) ([]UserWebAuthnCredential, error)
CreateUserWebAuthnCredential(ctx context.Context, credential *UserWebAuthnCredential) error
UpdateUserWebAuthnCredential(ctx context.Context, userID, credentialID, credentialJSON []byte, usedAt time.Time) error
DeleteAllUserWebAuthnCredentials(ctx context.Context, userID []byte) error
}

View File

@@ -0,0 +1,29 @@
package auth
import (
"time"
"gorm.io/gorm"
"wucher/internal/shared/pkg/uuidv7"
)
type Role struct {
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
Code string `gorm:"type:varchar(64);uniqueIndex;column:code"`
Name string `gorm:"type:varchar(64);uniqueIndex;not null;column:name"`
Description string `gorm:"type:varchar(255);column:description"`
CreatedAt time.Time
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
}
func (Role) TableName() string { return "roles" }
func (r *Role) BeforeCreate(tx *gorm.DB) error {
if len(r.ID) == 0 {
r.ID = uuidv7.MustBytes()
}
return nil
}

View File

@@ -0,0 +1,28 @@
package auth
import (
"time"
"gorm.io/gorm"
"wucher/internal/shared/pkg/uuidv7"
)
type RolePermission struct {
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
RoleID []byte `gorm:"type:binary(16);index;uniqueIndex:ux_role_permission;not null;column:role_id"`
PermissionID []byte `gorm:"type:binary(16);index;uniqueIndex:ux_role_permission;not null;column:permission_id"`
CreatedAt time.Time
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
}
func (RolePermission) TableName() string { return "role_permissions" }
func (rp *RolePermission) BeforeCreate(tx *gorm.DB) error {
if len(rp.ID) == 0 {
rp.ID = uuidv7.MustBytes()
}
return nil
}

View File

@@ -0,0 +1,20 @@
package auth
import "context"
type RoleRepository interface {
CreateRole(ctx context.Context, role *Role) error
UpdateRole(ctx context.Context, role *Role) error
DeleteRole(ctx context.Context, id []byte) error
GetRoleByID(ctx context.Context, id []byte) (*Role, error)
GetRoleByName(ctx context.Context, name string) (*Role, error)
HasRolePermission(ctx context.Context, roleID []byte, permissionKey string) (bool, error)
GetPermissionByID(ctx context.Context, id []byte) (*Permission, error)
GetPermissionByKey(ctx context.Context, key string) (*Permission, error)
UpdatePermission(ctx context.Context, permission *Permission) error
ListPermissionsByRoleID(ctx context.Context, roleID []byte) ([]Permission, error)
ListPermissions(ctx context.Context, filter string, sort string, limit, offset int) ([]Permission, int64, error)
AssignPermission(ctx context.Context, roleID, permissionID []byte) (*RolePermission, error)
RemovePermission(ctx context.Context, roleID, permissionID []byte) error
ListRoles(ctx context.Context, filterName string, sort string, limit, offset int) ([]Role, int64, error)
}

View File

@@ -0,0 +1,32 @@
package auth
import (
"time"
"gorm.io/gorm"
"wucher/internal/shared/pkg/uuidv7"
)
type SecurityPINResetToken struct {
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
UserID []byte `gorm:"type:binary(16);index;not null;column:user_id"`
TokenHash []byte `gorm:"type:varbinary(32);uniqueIndex;not null;column:token_hash"`
ExpiresAt time.Time `gorm:"index;not null;column:expires_at"`
UsedAt *time.Time `gorm:"index;column:used_at"`
RequestedIP string `gorm:"type:varchar(128);column:requested_ip"`
UserAgent string `gorm:"type:varchar(255);column:user_agent"`
CreatedAt time.Time
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
}
func (SecurityPINResetToken) TableName() string { return "security_pin_reset_tokens" }
func (prt *SecurityPINResetToken) BeforeCreate(tx *gorm.DB) error {
if len(prt.ID) == 0 {
prt.ID = uuidv7.MustBytes()
}
return nil
}

View File

@@ -0,0 +1,30 @@
package auth
import (
"time"
"gorm.io/gorm"
"wucher/internal/shared/pkg/uuidv7"
)
type UserTOTP struct {
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
UserID []byte `gorm:"type:binary(16);uniqueIndex;not null;column:user_id"`
SecretEncrypted []byte `gorm:"type:varbinary(512);not null;column:secret_encrypted"`
Enabled bool `gorm:"type:tinyint(1);not null;default:0;column:enabled"`
LastUsedAt *time.Time `gorm:"column:last_used_at"`
CreatedAt time.Time
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
}
func (UserTOTP) TableName() string { return "user_totp" }
func (ut *UserTOTP) BeforeCreate(tx *gorm.DB) error {
if len(ut.ID) == 0 {
ut.ID = uuidv7.MustBytes()
}
return nil
}

View File

@@ -0,0 +1,58 @@
package auth
import (
"time"
"gorm.io/gorm"
filemanager "wucher/internal/domain/file_manager"
"wucher/internal/shared/pkg/uuidv7"
)
// Note: UUIDv7 should be generated at application layer and stored as BINARY(16).
type User struct {
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
RoleID []byte `gorm:"type:binary(16);index;column:role_id"`
RoleIDs [][]byte `gorm:"-"`
Roles []Role `gorm:"-"`
ImageAttachmentID []byte `gorm:"type:binary(16);index;column:image_attachment_id"`
ProfileAttachmentID []byte `gorm:"type:binary(16);index;column:profile_attachment_id"`
Email string `gorm:"type:varchar(191);uniqueIndex;not null;column:email"`
SSOEmail *string `gorm:"type:varchar(191);uniqueIndex;column:sso_email"`
Username *string `gorm:"type:varchar(100);index;column:username"`
FirstName string `gorm:"type:varchar(100);not null;column:first_name"`
LastName string `gorm:"type:varchar(100);not null;column:last_name"`
MobilePhone string `gorm:"type:varchar(32);index;column:mobile_phone"`
Timezone string `gorm:"type:varchar(64);not null;default:UTC;column:timezone"`
SortKey *int `gorm:"index;column:sortkey"`
PasswordHash []byte `gorm:"type:varbinary(255);column:password_hash"`
SaltValue []byte `gorm:"type:varbinary(255);column:salt_value"`
// Stores PIN hash records ("<salt_b64>$<hash_b64>"); legacy rows may still contain encrypted values.
SecurityPINHash []byte `gorm:"type:varbinary(512);column:security_pin_hash"`
SecurityPINSetAt *time.Time `gorm:"column:security_pin_set_at"`
EmailVerifiedAt *time.Time `gorm:"column:email_verified_at"`
IsActive bool `gorm:"index;not null;default:true;column:is_active"`
CreatedAt time.Time
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
PilotProfile *PilotProfile `gorm:"foreignKey:UserID;references:ID"`
DoctorProfile *DoctorProfile `gorm:"foreignKey:UserID;references:ID"`
AirRescuerProfile *AirRescuerProfile `gorm:"foreignKey:UserID;references:ID"`
TechnicianProfile *TechnicianProfile `gorm:"foreignKey:UserID;references:ID"`
FlightAssistantProfile *FlightAssistantProfile `gorm:"foreignKey:UserID;references:ID"`
StaffProfile *StaffProfile `gorm:"foreignKey:UserID;references:ID"`
ImageAttachment *filemanager.Attachment `gorm:"foreignKey:ImageAttachmentID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
ProfileAttachment *filemanager.Attachment `gorm:"foreignKey:ProfileAttachmentID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
}
func (User) TableName() string { return "users" }
func (u *User) BeforeCreate(tx *gorm.DB) error {
if len(u.ID) == 0 {
u.ID = uuidv7.MustBytes()
}
return nil
}

View File

@@ -0,0 +1,28 @@
package auth
import (
"time"
"gorm.io/gorm"
"wucher/internal/shared/pkg/uuidv7"
)
type UserRole struct {
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
UserID []byte `gorm:"type:binary(16);index;uniqueIndex:ux_user_role;not null;column:user_id"`
RoleID []byte `gorm:"type:binary(16);index;uniqueIndex:ux_user_role;not null;column:role_id"`
CreatedAt time.Time
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
}
func (UserRole) TableName() string { return "user_roles" }
func (ur *UserRole) BeforeCreate(tx *gorm.DB) error {
if len(ur.ID) == 0 {
ur.ID = uuidv7.MustBytes()
}
return nil
}

View File

@@ -0,0 +1,30 @@
package auth
import (
"time"
"gorm.io/gorm"
"wucher/internal/shared/pkg/uuidv7"
)
// UserWebAuthnCredential stores a passkey credential bound to a user.
type UserWebAuthnCredential struct {
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
UserID []byte `gorm:"type:binary(16);index;not null;column:user_id"`
Name string `gorm:"type:varchar(100);column:name"`
CredentialID []byte `gorm:"type:varbinary(512);uniqueIndex;not null;column:credential_id"`
CredentialJSON []byte `gorm:"type:mediumblob;not null;column:credential_json"`
LastUsedAt *time.Time `gorm:"column:last_used_at"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (UserWebAuthnCredential) TableName() string { return "user_webauthn_credentials" }
func (c *UserWebAuthnCredential) BeforeCreate(tx *gorm.DB) error {
if len(c.ID) == 0 {
c.ID = uuidv7.MustBytes()
}
return nil
}