Documentation
¶
Overview ¶
Package service holds the business logic that sits between the HTTP handlers and the store.
Index ¶
- Constants
- Variables
- func ApplicationLogoPath(tenantCode, logoID string) string
- func CASAttributeFor(out Outbound, key string) (name string, send bool)
- func CASAttributeNames() map[string]string
- func ClaimFor(out Outbound, key string) (name string, send, renamed bool)
- func CommandLineActor(tenantID string) auth.Principal
- func ImportTemplate() (*excelize.File, error)
- func IsBuiltInFieldKey(key string) bool
- func MatchCASService(prefix, service string) bool
- func OIDCClaimNames() map[string]string
- func OIDCDefaultClaim(key string) (string, bool)
- func SAMLAttributeNames() map[string]SAMLAttribute
- func ValidDeliveryFilter(f string) bool
- type AddedAttribute
- type ApplicationLogoService
- func (s *ApplicationLogoService) Get(ctx context.Context, tenantID, id string) (Logo, error)
- func (s *ApplicationLogoService) SweepOrphans(ctx context.Context, tenantID string, now time.Time) (int64, error)
- func (s *ApplicationLogoService) Upload(ctx context.Context, actor auth.Principal, file io.Reader) (string, error)
- type AuditEntry
- type AuditQuery
- type AuditService
- type BulkOutcome
- type BulkResult
- type CASAddition
- type CASService
- func (s *CASService) Get(ctx context.Context, tenantID, prefix string) (model.CASService, error)
- func (s *CASService) GetByID(ctx context.Context, tenantID, id string) (model.CASService, error)
- func (s *CASService) IssueTicket(ctx context.Context, tenantID, userID, service string) (IssuedTicket, error)
- func (s *CASService) List(ctx context.Context, tenantID string) ([]model.CASService, error)
- func (s *CASService) Match(ctx context.Context, tenantID, service string) (model.CASService, error)
- func (s *CASService) Register(ctx context.Context, actor auth.Principal, in RegisterCASInput) (model.CASService, error)
- func (s *CASService) SetStatus(ctx context.Context, actor auth.Principal, prefix string, status model.Status) (model.CASService, error)
- func (s *CASService) SweepExpiredTickets(ctx context.Context, tenantID string) error
- func (s *CASService) Update(ctx context.Context, actor auth.Principal, currentPrefix string, ...) (model.CASService, error)
- func (s *CASService) ValidateTicket(ctx context.Context, tenantID, ticket, service string) (ValidatedTicket, error)
- type CreateUserInput
- type CreatedSubscription
- type Delivery
- type DeliveryDetail
- type DeliveryPage
- type DirectoryReader
- type DirectoryService
- func (s *DirectoryService) Get(ctx context.Context, tenantID, id string) (model.LDAPSource, error)
- func (s *DirectoryService) List(ctx context.Context, tenantID string) ([]model.LDAPSource, error)
- func (s *DirectoryService) Register(ctx context.Context, actor auth.Principal, in LDAPSourceInput) (model.LDAPSource, error)
- func (s *DirectoryService) Runs(ctx context.Context, tenantID, sourceID string, limit int) ([]model.LDAPSyncRun, error)
- func (s *DirectoryService) SetStatus(ctx context.Context, actor auth.Principal, id string, status model.Status) (model.LDAPSource, error)
- func (s *DirectoryService) SyncDue(ctx context.Context, tenantID string, now time.Time) ([]model.LDAPSyncRun, error)
- func (s *DirectoryService) SyncNow(ctx context.Context, actor auth.Principal, sourceID string) (model.LDAPSyncRun, error)
- func (s *DirectoryService) Update(ctx context.Context, actor auth.Principal, id string, in LDAPSourceInput) (model.LDAPSource, error)
- type EventPublisher
- type ExternalIDP
- type ExternalIDPInput
- type ExternalIDPService
- func (s *ExternalIDPService) BoundCount(ctx context.Context, tenantID, id string) (int64, error)
- func (s *ExternalIDPService) CompleteExternalSignIn(ctx context.Context, tenant model.Tenant, state, code, ip, userAgent string) (ExternalOutcome, error)
- func (s *ExternalIDPService) Create(ctx context.Context, actor auth.Principal, tenantCode string, ...) (ExternalIDP, error)
- func (s *ExternalIDPService) Delete(ctx context.Context, actor auth.Principal, id string) error
- func (s *ExternalIDPService) Get(ctx context.Context, tenantID, tenantCode, id string) (ExternalIDP, error)
- func (s *ExternalIDPService) IdentitiesFor(ctx context.Context, tenantID, userID string) ([]ExternalIdentity, error)
- func (s *ExternalIDPService) List(ctx context.Context, tenantID, tenantCode string) ([]ExternalIDP, error)
- func (s *ExternalIDPService) RedirectURI(tenantCode string) string
- func (s *ExternalIDPService) SetStatus(ctx context.Context, actor auth.Principal, id string, status model.Status) error
- func (s *ExternalIDPService) SignInOptions(ctx context.Context, tenantID string) ([]SignInOption, error)
- func (s *ExternalIDPService) StartExternalSignIn(ctx context.Context, tenant model.Tenant, providerID, userID string) (string, error)
- func (s *ExternalIDPService) Unbind(ctx context.Context, tenantID, userID, id string) error
- func (s *ExternalIDPService) Update(ctx context.Context, actor auth.Principal, tenantCode, id string, ...) (ExternalIDP, error)
- type ExternalIdentity
- type ExternalOutcome
- type Field
- type FieldCatalogue
- func (c *FieldCatalogue) CASAdditions(ctx context.Context, tenantID string, user model.User, out Outbound) ([]CASAddition, error)
- func (c *FieldCatalogue) Field(ctx context.Context, tenantID, key string) (Field, error)
- func (c *FieldCatalogue) FieldValues(ctx context.Context, tenantID string, user model.User) (map[string]string, error)
- func (c *FieldCatalogue) Fields(ctx context.Context, tenantID string) ([]Field, error)
- func (c *FieldCatalogue) OIDCAdditions(ctx context.Context, tenantID string, user model.User, out Outbound) (map[string]any, error)
- func (c *FieldCatalogue) SAMLAdditions(ctx context.Context, tenantID string, user model.User, out Outbound) ([]AddedAttribute, error)
- type FieldMappingInput
- type FieldMappingService
- func (s *FieldMappingService) Mappings(ctx context.Context, tenantID string, ref store.RecipientRef) ([]model.FieldMapping, error)
- func (s *FieldMappingService) OutboundFor(ctx context.Context, tenantID string, ref store.RecipientRef) (Outbound, error)
- func (s *FieldMappingService) Recipient(ctx context.Context, tenantID string, kind RecipientKind, id string) (store.RecipientRef, error)
- func (s *FieldMappingService) Replace(ctx context.Context, actor auth.Principal, ref store.RecipientRef, ...) ([]model.FieldMapping, error)
- type GroupInput
- type GroupService
- func (s *GroupService) AddMembers(ctx context.Context, tenantID, groupID string, userIDs []string, ...) error
- func (s *GroupService) Create(ctx context.Context, tenantID string, in GroupInput, source model.GroupSource, ...) (model.Group, error)
- func (s *GroupService) Delete(ctx context.Context, tenantID, id string, actor auth.Principal) error
- func (s *GroupService) FindByDisplayName(ctx context.Context, tenantID, name string) (model.Group, error)
- func (s *GroupService) FindByExternalID(ctx context.Context, tenantID, externalID string) (model.Group, error)
- func (s *GroupService) Get(ctx context.Context, tenantID, id string) (model.Group, error)
- func (s *GroupService) GroupsForUser(ctx context.Context, tenantID, userID string) ([]model.GroupRef, error)
- func (s *GroupService) List(ctx context.Context, tenantID string) ([]model.Group, error)
- func (s *GroupService) Members(ctx context.Context, tenantID, groupID string) ([]model.GroupMember, error)
- func (s *GroupService) RemoveMembers(ctx context.Context, tenantID, groupID string, userIDs []string, ...) error
- func (s *GroupService) ReplaceMembers(ctx context.Context, tenantID, groupID string, userIDs []string, ...) error
- func (s *GroupService) Update(ctx context.Context, tenantID, id string, in GroupInput, actor auth.Principal) (model.Group, error)
- func (s *GroupService) WithEvents(publisher EventPublisher) *GroupService
- type ImportResult
- type ImportRowError
- type IssuedSCIMCredential
- type IssuedTicket
- type LDAPSourceInput
- type Logo
- type MappingDirection
- type OAuthClientService
- func (s *OAuthClientService) Get(ctx context.Context, tenantID, clientID string) (model.OAuthClient, error)
- func (s *OAuthClientService) List(ctx context.Context, tenantID string) ([]model.OAuthClient, error)
- func (s *OAuthClientService) Register(ctx context.Context, actor auth.Principal, in RegisterClientInput) (RegisteredClient, error)
- func (s *OAuthClientService) RotateSecret(ctx context.Context, actor auth.Principal, clientID string) (RegisteredClient, error)
- func (s *OAuthClientService) SetStatus(ctx context.Context, actor auth.Principal, clientID string, ...) (model.OAuthClient, error)
- func (s *OAuthClientService) Update(ctx context.Context, actor auth.Principal, clientID string, ...) (model.OAuthClient, error)
- func (s *OAuthClientService) VerifySecret(ctx context.Context, tenantID, clientID, secret string) error
- type OrganizationInput
- type OrganizationService
- func (s *OrganizationService) AdministeredOrganizations(ctx context.Context, tenantID, userID string) ([]model.AdministeredOrganization, error)
- func (s *OrganizationService) Administrators(ctx context.Context, tenantID, organizationID string) ([]model.OrganizationAdministrator, error)
- func (s *OrganizationService) AssignAdministrator(ctx context.Context, actor auth.Principal, ...) error
- func (s *OrganizationService) AttachUser(ctx context.Context, actor auth.Principal, organizationID, userID string) error
- func (s *OrganizationService) Attachments(ctx context.Context, tenantID, userID string) ([]model.OrganizationRef, error)
- func (s *OrganizationService) Create(ctx context.Context, actor auth.Principal, in OrganizationInput) (model.Organization, error)
- func (s *OrganizationService) DetachUser(ctx context.Context, actor auth.Principal, organizationID, userID string) error
- func (s *OrganizationService) Get(ctx context.Context, tenantID, id string) (model.Organization, error)
- func (s *OrganizationService) List(ctx context.Context, tenantID string, activeOnly bool) ([]model.Organization, error)
- func (s *OrganizationService) RevokeAdministrator(ctx context.Context, actor auth.Principal, organizationID, userID string) error
- func (s *OrganizationService) SetManager(ctx context.Context, actor auth.Principal, organizationID, managerID string) (model.Organization, error)
- func (s *OrganizationService) SetStatus(ctx context.Context, actor auth.Principal, id string, status model.Status) (model.Organization, error)
- func (s *OrganizationService) Update(ctx context.Context, actor auth.Principal, id string, in OrganizationInput) (model.Organization, error)
- func (s *OrganizationService) WithEvents(publisher EventPublisher) *OrganizationService
- type Outbound
- type Page
- type PasswordPolicy
- type ProfileInput
- type ProvisionUserInput
- type RecipientKind
- type RecoveryService
- func (s *RecoveryService) AvailableChannels() []model.RecoveryChannel
- func (s *RecoveryService) Confirm(ctx context.Context, tenantID, token, newPassword, ip string) error
- func (s *RecoveryService) RecoverySentToday(ctx context.Context, tenantID, userID string) (int, error)
- func (s *RecoveryService) Request(ctx context.Context, tenant model.Tenant, channel model.RecoveryChannel, ...) error
- type RegisterCASInput
- type RegisterClientInput
- type RegisterInput
- type RegisterSPInput
- type RegisteredClient
- type SAMLAttribute
- type SAMLKey
- type SAMLKeyService
- type SAMLServiceProviderService
- func (s *SAMLServiceProviderService) Descriptor(ctx context.Context, tenantID, entityID string) (*saml.EntityDescriptor, error)
- func (s *SAMLServiceProviderService) Get(ctx context.Context, tenantID, entityID string) (model.SAMLServiceProvider, error)
- func (s *SAMLServiceProviderService) GetByID(ctx context.Context, tenantID, id string) (model.SAMLServiceProvider, error)
- func (s *SAMLServiceProviderService) List(ctx context.Context, tenantID string) ([]model.SAMLServiceProvider, error)
- func (s *SAMLServiceProviderService) Register(ctx context.Context, actor auth.Principal, in RegisterSPInput) (model.SAMLServiceProvider, error)
- func (s *SAMLServiceProviderService) SetStatus(ctx context.Context, actor auth.Principal, entityID string, ...) (model.SAMLServiceProvider, error)
- func (s *SAMLServiceProviderService) Update(ctx context.Context, actor auth.Principal, entityID string, in UpdateSPInput) (model.SAMLServiceProvider, error)
- type SCIMCredential
- type SCIMCredentialService
- func (s *SCIMCredentialService) Authenticate(ctx context.Context, token string) (SCIMPrincipal, error)
- func (s *SCIMCredentialService) Create(ctx context.Context, actor auth.Principal, name string) (IssuedSCIMCredential, error)
- func (s *SCIMCredentialService) Delete(ctx context.Context, actor auth.Principal, id string) error
- func (s *SCIMCredentialService) List(ctx context.Context, tenantID string) ([]SCIMCredential, error)
- func (s *SCIMCredentialService) SetStatus(ctx context.Context, actor auth.Principal, id string, status model.Status) error
- type SCIMPrincipal
- type Session
- type SessionService
- func (s *SessionService) CheckSession(ctx context.Context, tenantID, sessionID string) error
- func (s *SessionService) List(ctx context.Context, tenantID, userID, currentSessionID string) ([]model.Session, error)
- func (s *SessionService) Revoke(ctx context.Context, actor auth.Principal, userID, sessionID string) error
- func (s *SessionService) RevokeAllForUser(ctx context.Context, tenantID, userID string) error
- type Settings
- func (s Settings) LockoutDuration() time.Duration
- func (s Settings) LockoutEnabled() bool
- func (s Settings) OIDCAccessTokenLifetime() time.Duration
- func (s Settings) OIDCRefreshTokenLifetime() time.Duration
- func (s Settings) OIDCSessionCapped() bool
- func (s Settings) OIDCSessionMaxAge() time.Duration
- func (s Settings) PasswordPolicy() PasswordPolicy
- func (s Settings) TokenTTL() time.Duration
- type SettingsService
- func (s *SettingsService) CanDeliver() bool
- func (s *SettingsService) Defaults() Settings
- func (s *SettingsService) Get(ctx context.Context, tenantID string) (Settings, error)
- func (s *SettingsService) MessageLocale(ctx context.Context, tenantID, accountPreference string) i18n.Locale
- func (s *SettingsService) RegistrationEnabled(ctx context.Context, tenantID string) (bool, error)
- func (s *SettingsService) Update(ctx context.Context, tenantID string, next Settings) (Settings, error)
- func (s *SettingsService) WithDefaultLocale(locale string)
- func (s *SettingsService) WithDeliveryChannels(channels func() []model.RecoveryChannel)
- type SignInOption
- type SigningKey
- type SigningKeyService
- type SnapshotSource
- type SnapshotSummary
- type Subscription
- type SubscriptionInput
- type TenantFill
- type TenantFiller
- type TenantService
- func (s *TenantService) Create(ctx context.Context, code, name string, expiresAt *time.Time) (model.Tenant, error)
- func (s *TenantService) EnsureDefault(ctx context.Context) (model.Tenant, error)
- func (s *TenantService) Extend(ctx context.Context, code string, by time.Duration) (model.Tenant, error)
- func (s *TenantService) Get(ctx context.Context, id string) (model.Tenant, error)
- func (s *TenantService) List(ctx context.Context) ([]model.Tenant, error)
- func (s *TenantService) OperatorConsole() bool
- func (s *TenantService) Overview(ctx context.Context) ([]model.TenantOverview, error)
- func (s *TenantService) Resolve(ctx context.Context, code string) (model.Tenant, error)
- func (s *TenantService) SetExpiry(ctx context.Context, code string, at *time.Time) (model.Tenant, error)
- func (s *TenantService) SetStatus(ctx context.Context, code string, status model.Status) (model.Tenant, error)
- func (s *TenantService) WithOperatorConsole(on bool) *TenantService
- type TrialRequestInput
- type TrialService
- func (s *TrialService) Confirm(ctx context.Context, token, locale string) (TrialTenant, error)
- func (s *TrialService) Enabled() bool
- func (s *TrialService) Industries() []string
- func (s *TrialService) Request(ctx context.Context, in TrialRequestInput, ip string) error
- func (s *TrialService) SweepExpired(ctx context.Context) (int64, error)
- func (s *TrialService) SweepTenants(ctx context.Context) (disabled, deleted int, err error)
- func (s *TrialService) WithBlockedEmailDomains(extra []string) *TrialService
- func (s *TrialService) WithFillLimit(n int) *TrialService
- func (s *TrialService) WithFiller(f TenantFiller) *TrialService
- func (s *TrialService) WithLocale(locale string) *TrialService
- func (s *TrialService) WithMetrics(reg *metrics.Registry) *TrialService
- type TrialTenant
- type UpdateCASInput
- type UpdateClientInput
- type UpdateSPInput
- type UpdateUserInput
- type UserAttributeInput
- type UserAttributeService
- func (s *UserAttributeService) Define(ctx context.Context, actor auth.Principal, in UserAttributeInput) (model.UserAttributeDefinition, error)
- func (s *UserAttributeService) Definitions(ctx context.Context, tenantID string) ([]model.UserAttributeDefinition, error)
- func (s *UserAttributeService) Delete(ctx context.Context, actor auth.Principal, id string) error
- func (s *UserAttributeService) SetStatus(ctx context.Context, actor auth.Principal, id string, status model.Status) (model.UserAttributeDefinition, error)
- func (s *UserAttributeService) SetValues(ctx context.Context, actor auth.Principal, userID string, ...) error
- func (s *UserAttributeService) Update(ctx context.Context, actor auth.Principal, id string, in UserAttributeInput) (model.UserAttributeDefinition, error)
- func (s *UserAttributeService) Values(ctx context.Context, tenantID, userID string) (map[string]string, error)
- type UserQuery
- type UserService
- func (s *UserService) BulkSetOrganization(ctx context.Context, actor auth.Principal, userIDs []string, ...) (BulkResult, error)
- func (s *UserService) BulkSetStatus(ctx context.Context, actor auth.Principal, userIDs []string, ...) (BulkResult, error)
- func (s *UserService) ChangeExpiredPassword(ctx context.Context, tenant model.Tenant, ...) (Session, error)
- func (s *UserService) ChangeOwnPassword(ctx context.Context, actor auth.Principal, ...) error
- func (s *UserService) ClearRecoveryLimit(ctx context.Context, actor auth.Principal, userID string) (model.User, error)
- func (s *UserService) CloseOwnAccount(ctx context.Context, actor auth.Principal, password, ip string) error
- func (s *UserService) Create(ctx context.Context, tenantID string, in CreateUserInput) (model.User, error)
- func (s *UserService) EnsureInitialAdmin(ctx context.Context, tenantID, username, password string) (created bool, mustChange bool, err error)
- func (s *UserService) ExportUsers(ctx context.Context, actor auth.Principal, q UserQuery) (*excelize.File, int, error)
- func (s *UserService) FindByExternalID(ctx context.Context, tenantID, externalID string) (model.User, error)
- func (s *UserService) Get(ctx context.Context, tenantID, userID string) (model.User, error)
- func (s *UserService) ImportUsers(ctx context.Context, actor auth.Principal, r io.Reader, ip string) (ImportResult, error)
- func (s *UserService) IssueSessionForExternalIdentity(ctx context.Context, tenant model.Tenant, userID, ip, userAgent string) (session Session, err error)
- func (s *UserService) List(ctx context.Context, tenantID string, q UserQuery, page Page) ([]model.User, int64, error)
- func (s *UserService) Login(ctx context.Context, tenant model.Tenant, ...) (session Session, err error)
- func (s *UserService) Logout(ctx context.Context, actor auth.Principal, ip string) error
- func (s *UserService) LogoutEverywhere(ctx context.Context, actor auth.Principal, ip string) error
- func (s *UserService) LookupForAuth(ctx context.Context, userID string) (auth.Account, error)
- func (s *UserService) PasswordExpiryFor(ctx context.Context, tenantID, userID string) (*time.Time, error)
- func (s *UserService) PasswordPolicyFor(ctx context.Context, tenantID string) (PasswordPolicy, error)
- func (s *UserService) ProvisionUser(ctx context.Context, tenantID string, in ProvisionUserInput) (model.User, error)
- func (s *UserService) Register(ctx context.Context, tenantID string, in RegisterInput, ip string) (model.User, error)
- func (s *UserService) ResetPassword(ctx context.Context, actor auth.Principal, userID, newPassword, ip string) error
- func (s *UserService) SetProfile(ctx context.Context, actor auth.Principal, userID string, in model.UserProfile) (model.User, error)
- func (s *UserService) SetProvisionedUserActive(ctx context.Context, tenantID, userID string, active bool) (model.User, error)
- func (s *UserService) SetStatus(ctx context.Context, actor auth.Principal, userID string, status model.Status) (model.User, error)
- func (s *UserService) Unlock(ctx context.Context, actor auth.Principal, userID string) (model.User, error)
- func (s *UserService) Update(ctx context.Context, actor auth.Principal, userID string, in UpdateUserInput) (model.User, error)
- func (s *UserService) UpdateOwnProfile(ctx context.Context, actor auth.Principal, in ProfileInput, ip string) (model.User, error)
- func (s *UserService) UpdateProvisionedUser(ctx context.Context, tenantID, userID string, in ProvisionUserInput) (model.User, error)
- func (s *UserService) WithEvents(publisher EventPublisher) *UserService
- type ValidatedTicket
- type VerificationService
- func (s *VerificationService) Confirm(ctx context.Context, tenantID, token, ip string) error
- func (s *VerificationService) Required(ctx context.Context, tenantID string) (bool, error)
- func (s *VerificationService) Resend(ctx context.Context, tenant model.Tenant, destination, ip string)
- func (s *VerificationService) Send(ctx context.Context, tenant model.Tenant, userID string) error
- type WebhookService
- func (s *WebhookService) Create(ctx context.Context, actor auth.Principal, in SubscriptionInput) (CreatedSubscription, error)
- func (s *WebhookService) Delete(ctx context.Context, actor auth.Principal, id string) error
- func (s *WebhookService) Deliveries(ctx context.Context, tenantID, subscriptionID, cursor, filter string, ...) (DeliveryPage, error)
- func (s *WebhookService) Delivery(ctx context.Context, tenantID, subscriptionID, deliveryID string) (DeliveryDetail, error)
- func (s *WebhookService) DispatchDue(ctx context.Context, tenantID string, client *http.Client) (int, error)
- func (s *WebhookService) List(ctx context.Context, tenantID string) ([]Subscription, error)
- func (s *WebhookService) Publish(ctx context.Context, tenantID, eventType string, data any)
- func (s *WebhookService) RotateSecret(ctx context.Context, actor auth.Principal, id string) (CreatedSubscription, error)
- func (s *WebhookService) SetStatus(ctx context.Context, actor auth.Principal, id string, status model.Status) error
- func (s *WebhookService) SnapshotPreview(ctx context.Context, tenantID, subscriptionID string) (SnapshotSummary, error)
- func (s *WebhookService) StartSnapshot(ctx context.Context, actor auth.Principal, subscriptionID string) (SnapshotSummary, error)
- func (s *WebhookService) SweepDeliveries(ctx context.Context, tenantID string, now time.Time) error
- func (s *WebhookService) WithFieldMappings(catalogue *FieldCatalogue, mappings *FieldMappingService) *WebhookService
- func (s *WebhookService) WithSnapshotSource(src SnapshotSource) *WebhookService
- func (s *WebhookService) WithVault(v *secrets.Vault) *WebhookService
Constants ¶
const ( MaxLogoBytes = 512 << 10 // 512 KiB MaxLogoPixels = 1024 )
Bounds on an uploaded logo.
The byte cap is what a tile needs and nothing more: a 512×512 PNG of a company mark is a few tens of kilobytes, and the rows live in the same database as everything else, so this is also a bound on what a hostile administrator can put in a backup. The pixel cap exists because bytes are a poor proxy — a highly compressible image can be enormous when decoded, which is what a decompression bomb is.
const ( MinSyncIntervalMinutes = 15 MaxSyncIntervalMinutes = 7 * 24 * 60 )
Bounds on the automatic synchronization interval.
The floor is there because a synchronization has only one size: working out who has disappeared from a directory means listing everybody in it, so there is no cheap incremental pass to run every minute. Fifteen minutes is the shortest interval that is a schedule rather than a load test against somebody else's AD.
The ceiling is a week, past which "automatic" stops being a useful description of what is happening and a person should be pressing the button.
const ( FieldGroupIdentity = "identity" FieldGroupProfile = "profile" FieldGroupOrganization = "organization" FieldGroupTenant = "tenant" FieldGroupCustom = "custom" )
Field groups, which is how the console sorts the picker. Not semantic.
const ( FieldKindText = "TEXT" FieldKindNumber = "NUMBER" FieldKindBoolean = "BOOLEAN" FieldKindDate = "DATE" FieldKindSelect = "SELECT" )
FieldKinds a value may have. The five a tenant may define, plus the two the built-in set needs.
const ( // SettingTokenTTLMinutes is how long a sign-in to Portico's own console // lasts. Not the OIDC tokens issued to registered applications — those // are the three keys below, and conflating the two is easy enough that // the console labels this one "console session". SettingTokenTTLMinutes = "token_ttl_minutes" // The lifetimes of the tokens Portico issues as an OpenID Provider. // // These were constants until it became clear what that cost: the only // answer to "how long is an access token valid on this deployment" was to // read the source, and the only way to change it was to fork. What makes // them safe to expose is that each has a ceiling it cannot be set past — // see MaxOIDCAccessTokenTTLMinutes and the two beside it. // // SettingOIDCAccessTokenTTLMinutes governs the ID token as well. They are // the same duration, they were the same constant, and a second control // would be a second thing to get wrong for no gain: an ID token outliving // the access token it arrived with describes an authentication that may // already have been withdrawn. // #nosec G101 -- a settings key, not a credential. gosec pattern-matches // "token" in the name of a string constant; the value is a column key that // appears verbatim in the settings table. SettingOIDCAccessTokenTTLMinutes = "oidc_access_token_ttl_minutes" // SettingOIDCRefreshTokenTTLDays is how long a refresh token stays // usable. Each use rotates it and the replacement gets a fresh window, so // this bounds inactivity rather than the session — SettingOIDCSessionMaxAgeDays // is what bounds the session. // #nosec G101 -- a settings key, not a credential; same as above. SettingOIDCRefreshTokenTTLDays = "oidc_refresh_token_ttl_days" // SettingOIDCSessionMaxAgeDays is the absolute age of a refresh chain, // measured from the sign-in that started it rather than from the last // refresh. Without it a chain that is refreshed diligently never ends: // every rotation extends the window, so "thirty days" means thirty days // of silence, not thirty days of access. // // Zero switches it off and is the default, for the reason audit retention // keeps everything by default. This is the one setting here that ends // sessions which are working: shipping a cap would sign every long-lived // integration out that many days after an upgrade, on a schedule nobody // chose. SettingOIDCSessionMaxAgeDays = "oidc_session_max_age_days" // SettingRegistrationEnabled gates self-service registration, letting // the same build serve a closed intranet and an open internet // deployment (§3.10). SettingRegistrationEnabled = "registration_enabled" // SettingRegistrationVerification requires a self-registered account to // prove its contact address before it can sign in. // // Off by default, and a switch rather than a fixed rule: a closed // intranet where registration is already behind the network boundary // gains nothing from it, while a deployment facing outward cannot do // without it. Turning it on is refused where no channel can deliver — // see SettingsService.Update. SettingRegistrationVerification = "registration_verification" // SettingSystemName is shown in the UI header. SettingSystemName = "system_name" // SettingLockoutThreshold is how many consecutive failed sign-ins lock // an account. Zero switches lockout off. SettingLockoutThreshold = "lockout_threshold" // SettingLockoutDurationMinutes is how long a lock lasts, and also the // window failures are counted over — see UserService.Login. SettingLockoutDurationMinutes = "lockout_duration_minutes" // Password policy. All of these are off or permissive by default; see // password_policy.go for why composition rules and expiry are provided // but not recommended. SettingPasswordMinLength = "password_min_length" SettingPasswordRequireUppercase = "password_require_uppercase" SettingPasswordRequireLowercase = "password_require_lowercase" SettingPasswordRequireDigit = "password_require_digit" SettingPasswordRequireSymbol = "password_require_symbol" SettingPasswordHistoryDepth = "password_history_depth" SettingPasswordMaxAgeDays = "password_max_age_days" // SettingAuditRetentionDays is how long audit entries are kept. Zero — // the default — keeps them indefinitely. SettingAuditRetentionDays = "audit_retention_days" // SettingDefaultLocale is the language of messages this tenant sends to // somebody who has stated no preference of their own. Empty — the // default — follows the deployment's PORTICO_DEFAULT_LOCALE. // // Empty means "unset" rather than "English": one deployment can serve a // Chinese tenant and an English one, and a tenant that has said nothing // should follow whatever the deployment is changed to later rather than // having been frozen at install time. SettingDefaultLocale = "default_locale" // SettingShowGuides controls whether the explanatory panel at the top of // each administrative screen is offered at all. // // Tenant-wide rather than per-person, because it is asked as "our // operators know this product, stop showing them the introduction" — and // each panel is already individually collapsible and remembered per // browser, which is the per-person answer and was not enough. // // On by default. The people it costs are the ones who read every screen // daily and can collapse them; the people it helps are the ones who have // never seen the screen before, and a deployment that starts with them // hidden helps nobody. SettingShowGuides = "show_guides" )
Setting keys. These are the runtime-tunable values from §3.10.
const ( MinTokenTTLMinutes = 5 MaxTokenTTLMinutes = 60 * 24 * 30 // 30 days )
Bounds on the token lifetime. A value outside this range is almost certainly a mistake: too short locks everyone out, too long defeats expiry entirely.
const ( MinOIDCAccessTokenTTLMinutes = 1 MaxOIDCAccessTokenTTLMinutes = 60 MinOIDCRefreshTokenTTLDays = 1 MaxOIDCRefreshTokenTTLDays = 90 MaxOIDCSessionMaxAgeDays = 365 )
Bounds on the OIDC token lifetimes.
The access token's ceiling is the load-bearing one. That token is verified offline by a resource server that never calls back here, so it cannot be revoked: how soon it expires is the only thing that limits how long a withdrawn permission keeps working. An hour is already generous. A day — which is what somebody reaching for "make this less annoying" would pick — would mean a disabled account still being served for a day, and the administrator who disabled it would have no way to tell.
The refresh ceiling is ninety days because a refresh token that lives a year is a password that never rotates, held by a client that was never designed to protect one that long.
The session cap's ceiling is a year, and its floor is not 1 but 0: zero is the off switch and has to stay reachable, since turning a control off is a decision an operator is entitled to make deliberately.
const ( MaxLockoutThreshold = 100 MaxLockoutDurationMinutes = 60 * 24 )
Bounds on lockout.
The maximum threshold is deliberately low: a threshold of a thousand is not a lockout, it is a lockout that never fires, and an operator who set one would believe they had the control. The maximum duration is a day — anything longer is really "disable the account", which is a decision an administrator should make rather than a counter.
const ( MaxPasswordHistoryDepth = 24 MaxPasswordMinLength = 72 MaxPasswordMaxAgeDays = 3650 )
Bounds on the password policy.
The history depth is capped low because each entry costs a bcrypt comparison on every password change, and a change is exactly when somebody is waiting on a form. The minimum length cannot go below auth's floor, which applies whatever a tenant configures, and cannot exceed bcrypt's 72-byte limit — a policy demanding more than can be hashed would refuse every password.
const ( MinAuditRetentionDays = 7 MaxAuditRetentionDays = 3650 )
Bounds on audit retention.
The minimum is not zero-to-anything: a retention of one day is indistinguishable from an accident, and the difference between "we keep nothing" and "we keep a week" is the difference between an incident nobody can reconstruct and one somebody can. Zero is still available and still means keep everything — what is refused is the range where a typo destroys the trail. The maximum is ten years, past which nobody is deleting on a schedule anyway.
const ( DeliveryFilterAll = "all" DeliveryFilterLive = "live" DeliveryFilterSync = "sync" )
Delivery filters. Live hides the pages a full sync produces, which is the default because those are the deliveries there are most of and the ones least often being looked for: a hundred sync.users pages arriving in a few seconds push every ordinary event off the page somebody is reading.
const AuthRequestTTL = 15 * time.Minute
AuthRequestTTL is how long a sign-in may stay out at a provider.
Minutes. This is the window in which a stolen state is worth something, and somebody who wandered off mid-sign-in starts again for one click.
const DefaultInitialAdminPassword = "Portico@1"
DefaultInitialAdminPassword is what a bootstrap administrator gets when nobody chose a password for it.
It is documented, published, and identical on every installation, which would be indefensible on its own — so an account created with it cannot be used until it is replaced. See EnsureInitialAdmin.
The alternative, which this replaced, was a random password printed once to stderr. That is stronger against somebody who reaches a fresh instance first, and it failed people constantly: the line scrolled past, or the container runtime dropped it, or it went to a log collector nobody could read yet, and the deployment was then unopenable with no supported way back in — the bootstrap account has no email or phone, so recovery has no channel to use.
Its own constant rather than the seeded demo password, though the two currently read the same. They answer to different things: this one has to satisfy the default policy on a real installation, and that one only has to be typeable by whoever is being shown a demonstration.
const DirectoryActor = "directory sync"
DirectoryActor is who a synchronization's changes are attributed to.
Not a user id, because there is no account: the scheduler ran, or an administrator pressed a button and that press is audited separately as LDAP_SYNC. Recording a real administrator against every account the sync touched would put thousands of entries in the trail under somebody who made one decision.
const ExternalCallbackPath = "/external/callback"
ExternalCallbackPath is the console route that completes a sign-in.
Named here rather than written twice, because it is the one string in this feature that two systems have to agree on character for character: the console serves it, and somebody registers it at a provider that will refuse anything else.
const KindOIDC = "OIDC"
KindOIDC is a provider with a discovery document, which is every provider that is not one of the two written out in internal/socialrp.
const MaxBulkUsers = 500
MaxBulkUsers bounds one bulk request.
Each account is its own statement with its own audit entry and its own webhook, so a request of ten thousand would hold a connection for minutes and produce a result nobody can read. The console pages at a hundred; this is five of those.
const MaxCustomFieldsPerTenant = 50
MaxCustomFieldsPerTenant bounds how many attributes a tenant may define.
Not about storage. Every definition is a candidate for outbound mapping and a mapped attribute is bytes in an id_token, so an unbounded number of them makes token size something a tenant chooses by accident. Fifty is far past any real use and near enough to notice.
const MaxOrganizationDepth = 10
MaxOrganizationDepth bounds how deep the tree may go.
Not a schema constraint, because the schema cannot express it, and not arbitrary either: every check that walks upwards has to stop somewhere, and a bound that is never reached in practice is what makes those walks safe to write as simple loops. Ten is far past any organization chart anybody navigates willingly.
const OrphanRetention = 24 * time.Hour
OrphanRetention is how long an upload survives without being referenced.
An upload has to be stored before the form that would name it is saved, so a cancelled form leaves a row nobody points at. Long enough that somebody who uploaded a picture, went to lunch, and came back to finish the form still finds it there; short enough that abandoned uploads do not accumulate.
const ProvisioningActor = "scim"
ProvisioningActor is the actor name in the audit trail for a change no person made. It is not an account, and deliberately not one: an entry attributed to a user id that exists would be a lie about who acted.
Exported because the console filters the audit log by it to show what a directory has done, and a test asserts the two literals agree.
const RecoveryPerAccountPerDay = 5
RecoveryPerAccountPerDay is how many reset messages one account can be made to receive in a day.
The thing this protects is not in this deployment. It is the sending quota and the sender reputation, which every message spends and every tenant shares — and a burnt reputation takes password recovery down for the tenants that already exist, not for whoever caused it. That is the same argument that gave the trial endpoints trialsPerMailboxPerDay, and this endpoint had nothing equivalent: only the per-address rate limiter that sign-in has, which counts a minute and cannot see a mailbox at all.
Five rather than three, which is the trial figure. The person asking here is far more likely the account's owner — the message only ever goes to the address already bound to the account, never to the one submitted — so the legitimate retries are the real ones: the message went to spam, the link expired, they asked from their phone and then from their laptop.
Deployment-wide rather than a tenant setting, deliberately, and it is the question a reader will have. A tenant administrator raising their own cap would be spending a budget belonging to every other tenant on the deployment; the setting would put the decision with the party that does not bear its cost. Lockout is the opposite case and is a tenant setting for the opposite reason: it spends nothing outside the tenant.
const RecoveryTokenTTL = 30 * time.Minute
RecoveryTokenTTL is how long a reset link stays usable.
Short, because the token is a password equivalent for its lifetime and it sits in a mailbox. Long enough that someone who steps away between asking and reading does not have to ask again.
const ( // SAMLCertificateLifetime is how long a generated certificate is valid // for. // // Ten years, which is longer than this project would choose for anything // it could rotate on its own schedule. It cannot: a service provider // pins the certificate in configuration, often by hand, and an expiry // arriving unannounced takes the integration down at a moment nobody // chose. Rotation here is an operator's decision, not a clock's. SAMLCertificateLifetime = 10 * 365 * 24 * time.Hour )
const SecretOverlap = 24 * time.Hour
SecretOverlap is how long the replaced key keeps being sent alongside the new one.
Twenty-four hours because the work it is buying time for is somebody deploying a configuration change to another system, which is measured in working hours rather than minutes. Not configurable: the number that matters to a receiver is when the old key stops, and that is reported to them; a knob here would be a second thing to get wrong for no gain.
const ( // SigningKeyRetention is how long a retired key stays in the published // key set. // // It has to exceed the longest lifetime of anything the key signed, or // rotation invalidates live tokens. An hour of margin over the ID token // lifetime is the whole requirement; a day is generous and costs one row. SigningKeyRetention = 24 * time.Hour )
Key sizes and lifetimes.
const SnapshotPageSize = 500
SnapshotPageSize is how many objects ride in one delivery.
The number is a guess at what a receiver can write in one transaction, and it is deliberately not configurable yet: a wrong guess here shows up as timeouts at the receiver, which is a conversation to have with real deployments before it becomes a setting somebody has to understand.
const TrialIndustryGeneric = "generic"
TrialIndustryGeneric is what a request naming no industry gets.
The rest of the list comes from the filler rather than from a constant here. A trial names a world it wants seeded, the worlds are data in another package, and a copy of their names in this one would be a second list to keep in agreement — with the first symptom of disagreement being a visitor choosing an industry that turns out not to exist.
const TrialTenantGrace = 7 * 24 * time.Hour
TrialTenantGrace is how long a disabled trial tenant is kept before it is deleted.
The delay exists because deletion is the irreversible half and it releases three things at once: the tenant code, the quota slot, and the one-tenant- per-mailbox hold on the applicant's address. A week is long enough that somebody who let a deadline slip over a holiday still has their work, and short enough that a quota of fifty is not permanently held by tenants nobody has opened in a month.
const TrialTenantTTL = 14 * 24 * time.Hour
TrialTenantTTL is how long a trial tenant can be signed in to.
Two weeks, and stated as a fortnight rather than as fifteen days because that is a unit somebody holds in their head: an email saying "two weeks" needs no arithmetic, and one saying "15 days" does.
What reaching it does is disable the tenant, not delete it. So the deadline is not a threat — an operator can move it, and the person keeps everything they built if they ask.
const TrialTokenTTL = 24 * time.Hour
TrialTokenTTL is how long a confirmation link stays usable.
A day, the same as a registration verification. It was two hours, on the argument that an abandoned request holds a reserved tenant code and a shorter hold returns the name sooner. That is true and it was the wrong trade: somebody who asks for a trial in the evening and reads their mail the next morning is the ordinary case, not the abusive one, and the failure they met was a dead link with no way back to what they had typed.
What the hold costs is bounded by the limits below rather than by the clock. A code held for a day is only worth holding if a request can be made cheaply, and between the per-mailbox, per-client and whole-deployment caps, it cannot.
const UnassignedOrganization = "none"
UnassignedOrganization is what OrganizationID is set to in order to ask for the people who are in no organization at all.
An empty string cannot express it, because an empty string already means "every organization" — so before this existed there was no way to ask the question, and the accounts nobody has filed anywhere are exactly the ones somebody goes looking for. Safe as a reserved value because organization ids are UUIDs (see OrganizationService.Create) and can never be this.
const VerificationTokenTTL = 24 * time.Hour
VerificationTokenTTL is how long a verification link stays usable.
Longer than a reset link, on purpose. A reset is something somebody is waiting on with a form open; a registration is often finished later, and an expired link sends them back to a form they have already filled in. It is also a far weaker token: redeeming it grants nothing beyond marking an address proven.
Variables ¶
var ( ErrCASServiceNotFound = httpx.NotFound("CAS_SERVICE_NOT_FOUND", "No such CAS service.") ErrCASServiceTaken = httpx.Conflict("CAS_SERVICE_TAKEN", "That URL prefix is already registered in this tenant.") // ErrCASServiceNotRegistered is what an unregistered `service` parameter // gets. It is deliberately the same answer as a disabled one: a caller // probing for which services exist learns nothing either way. ErrCASServiceNotRegistered = httpx.Forbidden("CAS_SERVICE_NOT_REGISTERED", "That service is not registered with this server.") )
Errors from CAS registration and ticket validation.
var ( ErrCASTicketInvalid = fmt.Errorf("cas: the ticket is not valid") ErrCASServiceMismatch = fmt.Errorf("cas: the ticket was issued for another service") )
The two failures CAS distinguishes, which its own response format names.
var ( ErrLDAPSourceNotFound = httpx.NotFound("LDAP_SOURCE_NOT_FOUND", "No such directory.") ErrLDAPSourceNameTaken = httpx.Conflict("LDAP_SOURCE_NAME_TAKEN", "A directory with that name already exists.") ErrLDAPSourceDisabled = httpx.UnprocessableEntity("LDAP_SOURCE_DISABLED", "That directory is disabled.") ErrInvalidLDAPEncryption = httpx.BadRequest("INVALID_LDAP_ENCRYPTION", "Encryption must be none, starttls, or tls.") ErrLDAPFieldRequired = httpx.BadRequest("LDAP_FIELD_REQUIRED", "Host, base DN, user filter, and the username, display name, and external id attributes are all required.") ErrInvalidLDAPPort = httpx.BadRequest("INVALID_LDAP_PORT", "Port must be between 1 and 65535.") // ErrInvalidSyncInterval refuses rather than clamping, as the tenant // settings do: an operator who typed five minutes and was quietly given // fifteen would go on believing the directory is read four times as often // as it is. ErrInvalidSyncInterval = httpx.BadRequest("INVALID_SYNC_INTERVAL", "The automatic synchronization interval must be 0 to turn it off, "+ "or between 15 minutes and 7 days.") // ErrNoEncryptionKey is what a deployment with no PORTICO_ENCRYPTION_KEY // gets when it tries to store a bind password. Refusing is the point: // the alternative is a service account's credential sitting in a text // column, and nobody would find out until the database leaked. ErrNoEncryptionKey = httpx.UnprocessableEntity("NO_ENCRYPTION_KEY", "This deployment has no PORTICO_ENCRYPTION_KEY, so a bind password cannot be stored. "+ "Set one (openssl rand -hex 32) and restart, or use an anonymous bind.") )
Errors this service returns.
var ( ErrExternalIDPNotFound = httpx.NotFound("EXTERNAL_IDP_NOT_FOUND", "No such identity provider.") ErrExternalIDPIssuerTaken = httpx.Conflict("EXTERNAL_IDP_ISSUER_TAKEN", "This tenant already has a provider for that issuer.") )
Errors this service returns.
var ( // ErrExternalStateUnknown covers every way a callback fails to name a // live request: forged, replayed, expired, or already used. They are one // error deliberately — a caller who could tell them apart could use the // difference to learn which states existed. ErrExternalStateUnknown = httpx.UnprocessableEntity("EXTERNAL_STATE_UNKNOWN", "That sign-in could not be matched to one this server started. Begin again.") // ErrExternalIdentityUnknown is the refusal a first-time arrival meets // when nothing binds them to an account. ErrExternalIdentityUnknown = httpx.Unauthorized("EXTERNAL_IDENTITY_UNKNOWN", "That account is not linked here. Sign in with your password first, then link it from your profile.") ErrExternalIdentityTaken = httpx.Conflict("EXTERNAL_IDENTITY_TAKEN", "That identity is already linked to an account.") ErrExternalIdentityNotFound = httpx.NotFound("EXTERNAL_IDENTITY_NOT_FOUND", "No such linked identity.") )
Errors from the external sign-in journey.
var ( ErrMappingTargetRequired = httpx.BadRequest("MAPPING_TARGET_REQUIRED", "A mapping needs the name the application expects, unless it is suppressing the field.") // ErrReservedClaimName is the one refusal here with teeth. OpenID Connect // gives these claims meanings the protocol itself depends on, and a // mapping onto `sub` would tell an application that somebody is somebody // else — in a token it has every reason to trust. ErrReservedClaimName = httpx.BadRequest("RESERVED_CLAIM_NAME", "That claim name is reserved by OpenID Connect and carries a meaning the protocol depends on.") ErrDuplicateMappingSource = httpx.BadRequest("DUPLICATE_MAPPING_SOURCE", "Each field can be mapped once per recipient. Two rules for one field would be settled by whichever was read first.") ErrDuplicateMappingTarget = httpx.BadRequest("DUPLICATE_MAPPING_TARGET", "Two fields are being sent under the same name. Only one of them would arrive, and which one is not something you can choose.") // ErrPayloadNameTaken guards a webhook rename landing on a key the event // already uses for something else. A mapping onto `id` would put a // department where a subscriber reads the account's identifier — the same // hazard as a claim onto `sub`, one protocol down. ErrPayloadNameTaken = httpx.BadRequest("PAYLOAD_NAME_TAKEN", "The event payload already uses that name for something else.") // ErrClaimNameTaken is the same guard one protocol over. OpenID Connect // does not reserve `tenant_id` or `role` — they are this project's own // claims — so nothing else would stop a department being sent as the // tenant, in a claim a relying party reads as the tenant. ErrClaimNameTaken = httpx.BadRequest("CLAIM_NAME_TAKEN", "This application already receives another field under that claim name.") )
Errors this service returns.
var ( ErrGroupNotFound = httpx.NotFound("GROUP_NOT_FOUND", "No such group.") ErrGroupNameTaken = httpx.Conflict("GROUP_NAME_TAKEN", "A group with that name already exists.") ErrGroupExternalIDTaken = httpx.Conflict("GROUP_EXTERNAL_ID_TAKEN", "That externalId is already bound to another group.") // ErrMemberNotFound is deliberately not a silent skip. A membership push // naming an account that does not exist — or that belongs to another // tenant, which the composite foreign key catches — has to be reported, // because a silently dropped member is a group that looks synchronized // and is not. ErrMemberNotFound = httpx.BadRequest("MEMBER_NOT_FOUND", "One of the members does not exist in this tenant.") )
Errors this service returns.
var ( ErrClientNotFound = httpx.NotFound("CLIENT_NOT_FOUND", "No such client.") ErrClientIDTaken = httpx.Conflict("CLIENT_ID_TAKEN", "That client id is already registered in this tenant.") )
Errors from client registration and lookup.
var ( "Password recovery over that channel is not configured on this deployment.") ErrInvalidResetToken = httpx.UnprocessableEntity("INVALID_RESET_TOKEN", "That reset link is invalid, already used, or has expired. Request a new one.") )
Errors from password recovery.
var ( ErrServiceProviderNotFound = httpx.NotFound("SERVICE_PROVIDER_NOT_FOUND", "No such service provider.") ErrServiceProviderTaken = httpx.Conflict("SERVICE_PROVIDER_TAKEN", "That entity id is already registered in this tenant.") )
Errors from service-provider registration and lookup.
var ( ErrSCIMCredentialNotFound = httpx.NotFound("SCIM_CREDENTIAL_NOT_FOUND", "No such SCIM credential.") ErrSCIMCredentialNameTaken = httpx.Conflict("SCIM_CREDENTIAL_NAME_TAKEN", "A SCIM credential with that name already exists.") // whether the token was unknown, malformed, or belonged to a credential // somebody disabled. The client is a machine and cannot act on the // distinction; the operator can, and gets it from the audit trail and // the credential's own status rather than from a response that would // also tell an attacker which of their guesses was closest. ErrSCIMUnauthorized = httpx.Unauthorized("SCIM_UNAUTHORIZED", "The bearer token is not valid for SCIM.") )
Errors this service returns.
var ( ErrTenantNotFound = httpx.NotFound("TENANT_NOT_FOUND", "No such tenant.") ErrTenantDisabled = httpx.Forbidden("TENANT_DISABLED", "This tenant is disabled. Contact whoever operates this deployment.") ErrTenantCodeTaken = httpx.Conflict("TENANT_CODE_TAKEN", "That tenant code is already in use.") )
Errors from tenant resolution.
These name the tenant rather than hiding behind a generic failure. A tenant code is not a credential — it appears in sign-in URLs and in the configuration handed to every user of that tenant — so concealing whether one exists buys nothing and costs an operator a diagnosable error. Knowing a tenant exists still reveals nothing about the accounts in it.
var ( // ErrTrialSignupClosed is what every method answers when the deployment // has not enabled this. Registered routes are the real gate; this is the // backstop for a service constructed without one. ErrTrialSignupClosed = httpx.NotFound("TRIAL_SIGNUP_CLOSED", "This deployment does not offer self-service trials.") // ErrTrialQuotaReached is the shared demonstration being full. Said out // loud rather than swallowed: a visitor told to check their email waits // for a link that will never come. ErrTrialQuotaReached = httpx.Conflict("TRIAL_QUOTA_REACHED", "This demonstration is full. Try again later, or run Portico yourself.") // ErrTrialCodeTaken is the one failure a visitor can fix, which is why it // is reported before a link is sent rather than after. ErrTrialCodeTaken = httpx.Conflict("TRIAL_CODE_TAKEN", "That tenant code is already in use. Choose another.") // ErrTrialEmailUsed is one tenant per address, already spent. ErrTrialEmailUsed = httpx.Conflict("TRIAL_EMAIL_USED", "That address already has a trial tenant.") // ErrTrialTooManyFromAddress bounds one client address over a day, which // the per-minute throttle cannot see. ErrTrialTooManyFromAddress = httpx.TooManyRequests("TRIAL_TOO_MANY", "Too many trials requested from this address today.") // ErrTrialTooManyForMailbox is the same address having been mailed enough // times today. // // Worded for the person most likely to see it, who is somebody legitimate // asking a fourth time — not the attacker it exists to stop. ErrTrialTooManyForMailbox = httpx.TooManyRequests("TRIAL_TOO_MANY_FOR_EMAIL", "That address has already been sent several links today. Check your inbox and spam folder, or try again tomorrow.") // ErrTrialBusy is the whole demonstration having sent as much as it may // this hour. ErrTrialBusy = httpx.TooManyRequests("TRIAL_BUSY", "This demonstration is handing out trials faster than it may. Try again in an hour.") // ErrTrialEmailDomainBlocked is a throwaway mailbox. // // The address is the whole of the identity check, and what makes that // thin claim worth anything is that somebody could be reached at it // afterwards. A mailbox that expires in ten minutes is not that, and a // tenant traceable to one is traceable to nobody. ErrTrialEmailDomainBlocked = httpx.UnprocessableEntity("TRIAL_EMAIL_DOMAIN_BLOCKED", "That email provider is not accepted here. Use an address you can be reached at.") // ErrTrialMailFailed is the relay refusing the message. // // 503 rather than 500: the request was well formed, this server is // working, and something outside it is not. The visitor is told to try // again because that is genuinely what to do — the reservation is // released before this is returned, so the same details are free. ErrTrialMailFailed = httpx.ServiceUnavailable("TRIAL_MAIL_FAILED", "The confirmation email could not be sent just now. Try again in a minute.") // ErrTrialLinkInvalid is a token that names no request. ErrTrialLinkInvalid = httpx.BadRequest("TRIAL_LINK_INVALID", "That link is not valid. Request a new trial.") // ErrTrialLinkExpired is a link that outlived its day, and with it the // tenant code it was holding. ErrTrialLinkExpired = httpx.BadRequest("TRIAL_LINK_EXPIRED", "That link has expired. Request a new trial.") // ErrTrialLinkSpent is a second click. The credentials from the first are // valid, so this says to use them rather than reporting a broken link. ErrTrialLinkSpent = httpx.Conflict("TRIAL_LINK_SPENT", "That link has already been used. Sign in with the credentials it sent.") )
var ( ErrUserNotFound = httpx.NotFound("USER_NOT_FOUND", "No such user.") ErrUsernameTaken = httpx.Conflict("USERNAME_TAKEN", "That username is already in use.") ErrEmailTaken = httpx.Conflict("EMAIL_TAKEN", "That email address is already in use.") ErrPhoneTaken = httpx.Conflict("PHONE_TAKEN", "That phone number is already in use.") ErrOrganizationNotFound = httpx.NotFound("ORGANIZATION_NOT_FOUND", "No such organization.") ErrOrganizationDisabled = httpx.UnprocessableEntity("ORGANIZATION_DISABLED", "That organization is disabled and cannot take new members.") ErrCannotDisableSelf = httpx.UnprocessableEntity("CANNOT_DISABLE_SELF", "You cannot disable your own account.") ErrLastAdmin = httpx.UnprocessableEntity("LAST_ADMIN", "This is the only active administrator; promote another account first.") ErrInvalidCredentials = httpx.Unauthorized("INVALID_CREDENTIALS", "Incorrect username or password.") // ErrAccountLocked is returned to somebody whose password was right but // whose account is temporarily locked after repeated failures. It is // deliberately distinguishable from a wrong password: at this point the // caller has proved they know the password, so the only thing left to // tell them is why it did not work. ErrAccountLocked = httpx.Unauthorized("ACCOUNT_LOCKED", "Too many failed sign-in attempts. Try again later, or ask an administrator to unlock the account.") // ErrPasswordExpired is returned when the password is right but too old // to use. Like the locked and disabled answers, it is only reached after // the password has matched. ErrPasswordExpired = httpx.Unauthorized("PASSWORD_EXPIRED", "This password has expired and must be changed before signing in.") // ErrPasswordChangeRequired is returned when the password is right but is // one the account may not keep — the documented default a release // bootstraps its first administrator with. // // Separate from ErrPasswordExpired although both lead to the same form, // because the two say different things to the person reading them. "This // password has expired" in front of somebody who has just installed the // software and typed the password the manual gave them describes nothing // that happened, and the first thing they would do is go looking for the // expiry setting they must have got wrong. ErrPasswordChangeRequired = httpx.Unauthorized("PASSWORD_CHANGE_REQUIRED", "This account is still on its default password, which must be replaced before signing in.") ErrAccountDisabled = httpx.Unauthorized("ACCOUNT_DISABLED", "This account has been disabled.") ErrRegistrationDisabled = httpx.UnprocessableEntity("REGISTRATION_DISABLED", "Self-service registration is currently closed.") )
Errors surfaced to the API layer. They are httpx errors so the status and code are decided once, next to the rule that produced them.
var ( ErrUserAttributeNotFound = httpx.NotFound("USER_ATTRIBUTE_NOT_FOUND", "No such attribute.") // ErrUserAttributeKeyTaken covers both halves of the namespace, because to // the person typing it there is one namespace: a key that already names // something cannot name a second thing, and whether the first is built in // or their own colleague's does not change what they have to do. ErrUserAttributeKeyTaken = httpx.Conflict("USER_ATTRIBUTE_KEY_TAKEN", "That key is already in use. Keys have to be unique across both the built-in fields and your own.") ErrInvalidUserAttributeKey = httpx.BadRequest("INVALID_USER_ATTRIBUTE_KEY", "A key is 3 to 40 characters of lower-case letters, digits, and underscores, starting with a letter.") ErrInvalidUserAttributeKind = httpx.BadRequest("INVALID_USER_ATTRIBUTE_KIND", "The kind must be TEXT, NUMBER, BOOLEAN, DATE, or SELECT.") ErrUserAttributeLabelRequired = httpx.BadRequest("USER_ATTRIBUTE_LABEL_REQUIRED", "A label is required: it is what an operator sees on the form.") ErrUserAttributeNeedsValues = httpx.BadRequest("USER_ATTRIBUTE_NEEDS_VALUES", "A single-select attribute needs at least one permitted value.") // ErrTooManyUserAttributes is a bound on token size rather than on // storage, and says so: the number is small because every attribute is a // candidate for outbound mapping. ErrTooManyUserAttributes = httpx.UnprocessableEntity("TOO_MANY_USER_ATTRIBUTES", fmt.Sprintf("A tenant may define %d attributes. Each one is a candidate for outbound mapping, "+ "and a mapped attribute is bytes in every token.", MaxCustomFieldsPerTenant)) ErrInvalidUserAttributeValue = httpx.BadRequest("INVALID_USER_ATTRIBUTE_VALUE", "That value does not match the attribute's kind.") )
Errors this service returns.
var ( ErrWebhookNotFound = httpx.NotFound("WEBHOOK_NOT_FOUND", "No such subscription.") ErrWebhookNameTaken = httpx.Conflict("WEBHOOK_NAME_TAKEN", "A subscription with that name already exists.") )
Errors this service returns.
var ErrAccountClosed = httpx.Forbidden("ACCOUNT_CLOSED",
"This account was closed by its owner. An administrator can reinstate it.")
ErrAccountClosed is what a closed account gets at sign-in.
Distinct from ACCOUNT_DISABLED, and worth the extra code: the two call for different actions. Somebody an administrator suspended should talk to that administrator; somebody who closed their own account and now wants back in is asking for a different conversation, and being told "your account is disabled" would send them down the wrong path.
var ErrAccountUnverified = httpx.Forbidden("ACCOUNT_UNVERIFIED",
"This account has not confirmed its email address yet. Check for the message, or ask for another.")
ErrAccountUnverified is what a self-registered account gets before it has proved the address it gave.
Its own code rather than ACCOUNT_DISABLED, because the person can act on it: the sign-in screen offers to send the message again. Reporting it as disabled would send them to an administrator instead.
var ErrAlreadyOrganizationAdmin = httpx.Conflict("ALREADY_ORGANIZATION_ADMIN",
"That account is already recorded as an administrator of this organization. Remove it first to change its scope.")
ErrAlreadyOrganizationAdmin is a second assignment of the same person to the same organization.
var ErrDirectoryReturnedNothing = refusal{ // contains filtered or unexported fields }
ErrDirectoryReturnedNothing stops the single worst thing this code could do.
A search that matches nothing looks exactly like a directory in which everybody has left. The first is a typo in a base DN or a filter and happens regularly; the second essentially never happens, and if it did, nobody would want it applied automatically at three in the morning. So an empty result set against a source that owns accounts fails the run and changes nothing, and an operator reads the reason.
var ErrExternalIDTaken = httpx.Conflict("EXTERNAL_ID_TAKEN",
"That externalId is already bound to another account.")
ErrExternalIDTaken is returned when a provisioning identifier is already bound to a different account.
var ErrInvalidDeliveryCursor = httpx.BadRequest("INVALID_CURSOR",
"That page marker is not one this server issued. Start from the first page.")
ErrInvalidDeliveryCursor is a cursor this server did not issue.
var ErrInvalidLaunchURL = httpx.BadRequest("INVALID_LAUNCH_URL",
"A launch address must be an http or https URL.")
ErrInvalidLaunchURL is a launch address that must not be rendered as a link.
var ErrInvalidLogoURI = httpx.BadRequest("INVALID_LOGO_URI",
"A logo address must be an http or https URL, or a path on this server.")
ErrInvalidLogoURI is a logo address that must not be rendered as a picture.
var ErrInvalidVerificationToken = httpx.BadRequest("INVALID_VERIFICATION_TOKEN",
"That verification link is not valid or has already been used. Request another.")
ErrInvalidVerificationToken is what every way of failing to redeem a link returns.
Unknown, spent, and expired are one answer for the same reason they are in password recovery: distinguishing them tells somebody holding a stolen but dead link that it was once real, and a legitimate person does the same thing in all three cases — ask for another.
var ErrLogoTooLarge = httpx.BadRequest("LOGO_TOO_LARGE", fmt.Sprintf("A logo must be under %d KiB and no more than %d pixels on a side.", MaxLogoBytes>>10, MaxLogoPixels))
ErrLogoTooLarge is a file past the size or pixel bound.
var ErrManagerIsSelf = httpx.UnprocessableEntity("MANAGER_IS_SELF",
"An account cannot report to itself.")
ErrManagerIsSelf is returned for somebody reporting to themselves.
Longer chains are not checked. A cycle of two is always a mistake and costs one comparison to catch; a cycle of five is a data-quality problem in whatever system produced it, and finding one would mean a recursive query on every write of a field nothing in Portico reads for authorization.
var ErrManagerNotFound = httpx.UnprocessableEntity("MANAGER_NOT_FOUND",
"No such account to report to.")
ErrManagerNotFound is returned when the manager named on a profile is not an account in this tenant.
var ErrOrganizationAdminScope = httpx.BadRequest("INVALID_ADMIN_SCOPE",
"An administrator's scope must be SELF (this organization) or SUBTREE (it and everything under it).")
ErrOrganizationAdminScope is an assignment that did not say how far it reaches.
var ErrOrganizationCodeTaken = httpx.Conflict("ORGANIZATION_CODE_TAKEN",
"That organization code is already in use.")
ErrOrganizationCodeTaken is returned when a code is already in use within the tenant. Codes are unique per tenant, not globally: two tenants both having a "SALES" is expected.
var ErrOrganizationCycle = httpx.BadRequest("ORGANIZATION_CYCLE",
"That would put the organization inside itself or one of its own descendants.")
ErrOrganizationCycle is returned when a move would put an organization inside itself.
var ErrOrganizationManagerNotFound = httpx.UnprocessableEntity("ORGANIZATION_MANAGER_NOT_FOUND",
"No such account to put in charge of this organization.")
ErrOrganizationManagerNotFound is returned when the nominee is not an account in this tenant.
var ErrOrganizationTooDeep = httpx.BadRequest("ORGANIZATION_TOO_DEEP", fmt.Sprintf("Organizations may be nested at most %d levels deep.", MaxOrganizationDepth))
ErrOrganizationTooDeep is returned when a move would exceed the depth limit.
var ErrPasswordReused = httpx.BadRequest("PASSWORD_REUSED",
"That password has been used recently. Choose one you have not used before.")
ErrPasswordReused is returned when a new password matches a recent one.
var ErrProvisioningLastAdmin = httpx.UnprocessableEntity("LAST_ADMIN",
"That account is the only active administrator and cannot be deactivated.")
ErrProvisioningLastAdmin is returned when a sync would deactivate the last administrator.
The same rule the console enforces, and it applies here for a better reason: a directory that stops listing somebody is a routine event, and without this a leaver's last day would lock everyone out of the tenant with no way back in short of the database.
var ErrRecipientNotFound = httpx.NotFound("RECIPIENT_NOT_FOUND",
"No such application or subscription.")
ErrRecipientNotFound is what naming one that is not there gets.
Checked before writing rather than left to the foreign key. A mistyped id would otherwise surface as a constraint violation — a 500, describing a column, for what is an ordinary wrong-address mistake.
var ErrSessionNotFound = httpx.NotFound("SESSION_NOT_FOUND", "No such session.")
ErrSessionNotFound is returned when no live session has that id.
"This deployment cannot produce a snapshot.")
ErrSnapshotUnavailable is returned when nothing was attached to read from.
var ErrTenantHasNoExpiry = httpx.UnprocessableEntity("TENANT_HAS_NO_EXPIRY",
"This tenant has no expiry date, so there is nothing to extend.")
ErrTenantHasNoExpiry is a tenant nobody put on a clock.
var ErrTooManyBulkUsers = httpx.BadRequest("TOO_MANY_USERS", fmt.Sprintf("At most %d accounts at a time.", MaxBulkUsers))
ErrTooManyBulkUsers is returned for a request beyond that.
var ErrUnknownField = httpx.BadRequest("UNKNOWN_FIELD",
"No such field. The list of what may be mapped is at /api/v1/fields.")
ErrUnknownField is what naming a field the catalogue does not hold gets. It carries the key, because the usual cause is a typo in a mapping and the key is the only thing that identifies which one.
var ErrUnsupportedImage = httpx.BadRequest("UNSUPPORTED_IMAGE",
"A logo must be a PNG or JPEG image.")
ErrUnsupportedImage is a file that will not be stored as a logo.
One code for every rejection about the file's own content, with the specific reason in the message. A caller cannot act differently on "this is an SVG" than on "this is a text file that ends in .png", so splitting them would be codes nobody branches on.
"This deployment requires new accounts to verify an address and has no way to send one. "+
"An administrator has to configure a mail relay or switch the requirement off.")
ErrVerificationUnavailable is returned when verification is required and this deployment cannot send anything.
It is checked here as well as when the setting is saved. The setting is validated once, at the moment somebody turns it on; removing SMTP from the environment afterwards would leave it standing, and registration would create accounts nobody can ever verify.
var ErrWebhookDeliveryNotFound = httpx.NotFound("WEBHOOK_DELIVERY_NOT_FOUND",
"No such delivery. Finished deliveries are removed after 30 days.")
ErrWebhookDeliveryNotFound is a delivery id that is not this subscription's, or is past its retention.
var SAMLCommonName = SAMLAttribute{Name: "urn:oid:2.5.4.3", FriendlyName: "cn"}
SAMLCommonName is the second name the display name goes out under.
`cn` carries the same value as `displayName` and always has — crewjam derived it from the session, every assertion 0.1 issued had it, and a good many service providers map by it. It is not a catalogue key of its own, because it is not a separate fact: it is an alias.
So it follows `display_name`'s rule rather than having one. With no rule both go out, exactly as before. With a rule the fact goes out once, under the name the rule chose — because a rename that left `cn` still carrying the value would send the same fact twice under two names, which is what somebody renaming it is trying to stop.
Functions ¶
func ApplicationLogoPath ¶
ApplicationLogoPath is where an uploaded logo is served from.
Built here rather than in the console so that one place decides how the address is spelled. It goes into the same logo_uri column that has accepted a path on this server since migration 00003, and takes the tenant-prefixed form the federation endpoints already use — because the row belongs to a tenant and the request that reads it has no principal to take one from.
Deliberately not under /api. SecurityHeaders sets Cache-Control: no-store for that prefix, which is right for a payload carrying account data and exactly wrong for an immutable image fetched on every page load.
func CASAttributeFor ¶
CASAttributeFor decides one default attribute's fate under a recipient's rules.
func CASAttributeNames ¶
CASAttributeNames is the default set, for the guard tests that hold this table and the documentation in step.
func ClaimFor ¶
ClaimFor decides one default claim's fate under a recipient's rules.
renamed reports that the caller must append a claim under name rather than assign the typed field it would otherwise have set — which is the whole reason this returns three values instead of the two NameFor does.
func CommandLineActor ¶
CommandLineActor is who an administrative act is attributed to when it was performed with the `portico` command rather than through the API.
The user id is deliberately left empty, which the audit service stores as null: there was no user. Recording a real administrator's id would be a lie, and inventing a synthetic one would put a row in the trail that looks like an account somebody could go and disable.
The command line is not a lesser path that can skip the trail. Whoever reads the audit log later is asking "who let this application in", and "somebody with shell access, at this time" is a far better answer than silence.
func ImportTemplate ¶
ImportTemplate builds the blank workbook administrators fill in. Serving a generated template rather than a static file keeps the columns and the parser from drifting apart.
func IsBuiltInFieldKey ¶
IsBuiltInFieldKey reports whether a key is taken by the built-in half. A tenant-defined attribute may not use one: a mapping stores a key, and two entries under one key would make the mapping ambiguous.
func MatchCASService ¶
MatchCASService reports whether a service URL is covered by a registered prefix.
A literal prefix match with a boundary. Without the boundary check, a registration for https://app.example.com would match https://app.example.com.attacker.test — which is the whole reason CAS deployments get told to be careful with service matching, and it is not something to leave to whoever types the registration.
func OIDCClaimNames ¶
OIDCClaimNames is the default claim set, for the guard tests that hold the documentation and this table in step.
func OIDCDefaultClaim ¶
OIDCDefaultClaim is the claim name a catalogue key goes out as by default, and whether it goes out at all.
func SAMLAttributeNames ¶
func SAMLAttributeNames() map[string]SAMLAttribute
SAMLAttributeNames is the default set, for the guard tests that hold this table and the documentation in step.
func ValidDeliveryFilter ¶
ValidDeliveryFilter reports whether f is one this version serves.
Types ¶
type AddedAttribute ¶
type AddedAttribute struct {
Attribute SAMLAttribute
Value string
}
AddedAttribute is one configured attribute, resolved for an account.
type ApplicationLogoService ¶
type ApplicationLogoService struct {
// contains filtered or unexported fields
}
ApplicationLogoService stores and serves the pictures on application tiles.
func NewApplicationLogoService ¶
func NewApplicationLogoService(st *store.Store) *ApplicationLogoService
NewApplicationLogoService wires the service.
No audit dependency, unlike most services here. An upload is not yet a change to anything — the audited event is registering or editing the application that comes to reference it, which records who did it and when. A second entry for the upload would be an entry for an act with no effect.
func (*ApplicationLogoService) SweepOrphans ¶
func (s *ApplicationLogoService) SweepOrphans(ctx context.Context, tenantID string, now time.Time) (int64, error)
SweepOrphans deletes uploads that no application references and that are older than OrphanRetention. It reports how many it removed.
func (*ApplicationLogoService) Upload ¶
func (s *ApplicationLogoService) Upload( ctx context.Context, actor auth.Principal, file io.Reader, ) (string, error)
Upload validates a file and stores it, returning the logo's id.
The bytes are stored exactly as they arrived. Re-encoding them would be a way to guarantee the output is an image — and it would also silently change somebody's carefully made mark, lose an alpha channel or a colour profile, and turn every upload into a decode-encode cycle over untrusted input. What makes the stored file safe to serve is that it is one of two raster formats and is sent with the type it actually is.
type AuditEntry ¶
type AuditEntry struct {
Kind model.LogKind
Action string
Result model.LogResult
ActorID string
ActorName string
TargetType string
TargetID string
TargetName string
Detail string
IP string
}
AuditEntry describes an event to record.
The tenant is not a field here: it is a separate argument to Record and Log, so that adding a field to this struct can never be the reason an event lands in the wrong tenant's trail.
type AuditQuery ¶
type AuditQuery struct {
// Kind restricts results to one log kind; empty means all kinds.
Kind model.LogKind
// Action restricts results to one action verb; empty means all.
Action string
// Keyword matches the actor or target name.
Keyword string
// Actor restricts results to one actor, matched exactly.
//
// Separate from Keyword, which is a substring across two columns and is
// what a person types into a search box. This one answers "what did
// this actor do", and the caller that asks it is the provisioning
// screen asking about `scim`. Doing that through Keyword would also
// return every entry whose target happens to contain the word, and
// every account somebody named scim-service.
Actor string
// From and To bound created_at. Zero values are unbounded.
From time.Time
To time.Time
}
AuditQuery filters a log listing.
type AuditService ¶
type AuditService struct {
// contains filtered or unexported fields
}
AuditService writes and queries the audit trail.
func NewAuditService ¶
func NewAuditService(st *store.Store) *AuditService
NewAuditService returns a service backed by st.
func (*AuditService) List ¶
func (s *AuditService) List(ctx context.Context, tenantID string, q AuditQuery, page Page) ([]model.AuditLog, int64, error)
List returns a page of a tenant's log entries, newest first.
This query is hand-written rather than generated because the filters are optional: sqlc would need a separate query per combination. The tenant predicate stays in the SQL text so it is visible here and checked by the guard test in internal/store.
func (*AuditService) Log ¶
func (s *AuditService) Log(ctx context.Context, tenantID string, e AuditEntry)
Log records an entry, reporting a write failure to the process log rather than to the caller.
func (*AuditService) Record ¶
func (s *AuditService) Record(ctx context.Context, tenantID string, e AuditEntry) error
Record writes one entry into a tenant's trail.
A failure to write the audit trail must not fail the operation being audited — a user should not be unable to log in because logging is broken. The error is returned so callers may inspect it, but Log is the usual entry point and swallows it after logging.
type BulkOutcome ¶
type BulkOutcome struct {
UserID string `json:"userId"`
// Code is empty on success, and the error code otherwise. Per account
// rather than for the request as a whole: an operator selecting forty
// people and finding one of them is the last administrator needs to know
// which one, not that "it failed".
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
}
BulkOutcome is what one account in a bulk request did.
type BulkResult ¶
type BulkResult struct {
Total int `json:"total"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Outcomes []BulkOutcome `json:"outcomes"`
}
BulkResult summarizes a bulk request.
type CASAddition ¶
CASAddition is one configured attribute, resolved for an account.
type CASService ¶
type CASService struct {
// contains filtered or unexported fields
}
CASService issues and validates CAS service tickets.
There is no ticket-granting ticket. CAS's own design puts one in a long-lived cookie so a browser can obtain further tickets without signing in again — but Portico already has a session for exactly that, and a second long-lived credential would be a third thing that has to be revoked when somebody signs out, changes a password, or is disabled. Riding on the existing session means those three already cover it.
func NewCASService ¶
func NewCASService(st *store.Store, users *UserService, audit *AuditService) *CASService
NewCASService wires the service.
func (*CASService) Get ¶
func (s *CASService) Get(ctx context.Context, tenantID, prefix string) (model.CASService, error)
Get returns one registration by its exact prefix.
func (*CASService) GetByID ¶
func (s *CASService) GetByID(ctx context.Context, tenantID, id string) (model.CASService, error)
GetByID returns one registration by its own id. See SAMLServiceProviderService.GetByID for why the console addresses registrations this way rather than by URL prefix.
func (*CASService) IssueTicket ¶
func (s *CASService) IssueTicket(ctx context.Context, tenantID, userID, service string) (IssuedTicket, error)
IssueTicket mints a service ticket for a signed-in person.
func (*CASService) List ¶
func (s *CASService) List(ctx context.Context, tenantID string) ([]model.CASService, error)
List returns every CAS service in a tenant.
func (*CASService) Match ¶
func (s *CASService) Match(ctx context.Context, tenantID, service string) (model.CASService, error)
Match finds the registration covering a service URL, or reports that nothing does.
func (*CASService) Register ¶
func (s *CASService) Register(ctx context.Context, actor auth.Principal, in RegisterCASInput) (model.CASService, error)
Register adds a CAS service to the actor's tenant.
func (*CASService) SetStatus ¶
func (s *CASService) SetStatus(ctx context.Context, actor auth.Principal, prefix string, status model.Status) (model.CASService, error)
SetStatus enables or disables a CAS service.
func (*CASService) SweepExpiredTickets ¶
func (s *CASService) SweepExpiredTickets(ctx context.Context, tenantID string) error
SweepExpiredTickets deletes tickets nobody validated.
func (*CASService) Update ¶
func (s *CASService) Update(ctx context.Context, actor auth.Principal, currentPrefix string, in UpdateCASInput) (model.CASService, error)
Update changes a CAS registration's name and URL prefix.
func (*CASService) ValidateTicket ¶
func (s *CASService) ValidateTicket(ctx context.Context, tenantID, ticket, service string) (ValidatedTicket, error)
ValidateTicket spends a ticket and reports who it was issued for.
type CreateUserInput ¶
type CreateUserInput struct {
Username string
DisplayName string
Password string
Phone string
Email string
Role model.Role
OrganizationID string
Source model.UserSource
// MustChangePassword refuses this account at sign-in until the password
// is replaced. Set for a bootstrap administrator that took the documented
// default; nothing in the API offers it yet.
MustChangePassword bool
}
CreateUserInput is an administrator-initiated account creation.
type CreatedSubscription ¶
type CreatedSubscription struct {
Subscription
// Secret is returned on creation and never again — not because it is
// hashed (it cannot be, it signs) but because there is no reason to
// serve it a second time and every reason not to have an endpoint that
// does.
Secret string `json:"secret"`
// PreviousExpiry is when the key this replaced stops being sent, and is
// absent on a first issue because there is nothing it replaced. It is
// the one number the receiver has to act on: it is their deadline, not
// ours.
PreviousExpiry *time.Time `json:"previousSecretExpiresAt,omitempty"`
}
CreatedSubscription is what creation returns, once.
type Delivery ¶
type Delivery struct {
ID string `json:"id"`
EventType string `json:"eventType"`
Status string `json:"status"`
Attempts int32 `json:"attempts"`
LastStatus *int32 `json:"lastStatus"`
LastError string `json:"lastError"`
CreatedAt time.Time `json:"createdAt"`
DeliveredAt *time.Time `json:"deliveredAt"`
}
Delivery is one attempt's record, for the console.
type DeliveryDetail ¶
type DeliveryDetail struct {
Delivery
// Payload is the request body exactly as it was sent — the same bytes
// the signature was computed over, so a receiver comparing signatures
// has something to compare against.
Payload string `json:"payload"`
// Response is the beginning of what the receiver answered on the most
// recent attempt, capped when it was stored.
Response string `json:"response"`
// ResponseCap says where that cap is, so a screen can say "truncated"
// rather than leaving somebody to wonder whether the receiver stopped
// mid-sentence.
ResponseCap int `json:"responseCap"`
}
DeliveryDetail is one delivery with the bodies, which the list omits.
type DeliveryPage ¶
DeliveryPage is one page of a subscription's attempts, with the cursor that fetches the next.
NextCursor is empty when this is the last page. A count is deliberately absent: the table is written to while somebody reads it, so a total would be out of date before it arrived, and paging by cursor does not need one.
type DirectoryReader ¶
DirectoryReader is the part of a directory connection this service uses.
type DirectoryService ¶
type DirectoryService struct {
// contains filtered or unexported fields
}
DirectoryService registers directories and synchronizes accounts out of them.
This is the opposite direction from SCIM, which is worth stating because the two land in the same place. SCIM is a server here: a directory pushes and Portico never reaches out. LDAP is a pull, on Portico's initiative and its schedule. The failure modes differ accordingly — a push that stops is silent at this end, while a pull that stops leaves a failed run to point at, which is most of why the run records exist.
func NewDirectoryService ¶
func NewDirectoryService(st *store.Store, users *UserService, audit *AuditService, webhooks *WebhookService, vault *secrets.Vault) *DirectoryService
NewDirectoryService wires a DirectoryService.
func (*DirectoryService) Get ¶
func (s *DirectoryService) Get(ctx context.Context, tenantID, id string) (model.LDAPSource, error)
Get returns one directory.
func (*DirectoryService) List ¶
func (s *DirectoryService) List(ctx context.Context, tenantID string) ([]model.LDAPSource, error)
List returns the tenant's directories.
func (*DirectoryService) Register ¶
func (s *DirectoryService) Register(ctx context.Context, actor auth.Principal, in LDAPSourceInput) (model.LDAPSource, error)
Register adds a directory.
func (*DirectoryService) Runs ¶
func (s *DirectoryService) Runs(ctx context.Context, tenantID, sourceID string, limit int) ([]model.LDAPSyncRun, error)
Runs returns a directory's recent synchronizations, newest first.
func (*DirectoryService) SetStatus ¶
func (s *DirectoryService) SetStatus(ctx context.Context, actor auth.Principal, id string, status model.Status) (model.LDAPSource, error)
SetStatus enables or disables a directory. A disabled one is not synchronized and its accounts are left exactly as they are — disabling the connector must not deactivate the people it brought in.
func (*DirectoryService) SyncDue ¶
func (s *DirectoryService) SyncDue(ctx context.Context, tenantID string, now time.Time) ([]model.LDAPSyncRun, error)
SyncDue synchronizes the tenant's directories whose interval has elapsed, and returns the runs it performed. Nothing due is not an error.
The caller supplies the time, as the sweeps do, so a test can ask what would happen tomorrow without waiting for it.
It runs through SyncNow rather than beside it, with an actor that has no name. That is what keeps a scheduled run indistinguishable from a manual one everywhere it matters — the same run record, the same audit entry, the same refusal to act on an empty result — and the empty name is what the console renders as "scheduled", a distinction the schema reserved for it from the start.
One directory's failure does not stop the next. A source pointed at a host that has gone away is a common state, and letting it hold up the other directories in the tenant would make one team's mistake everybody's.
Claimed one at a time, immediately before each is read, rather than all at once up front. The difference shows when a pass does not finish: a directory this loop never reached has not been claimed, so it is still due — instead of carrying an attempt timestamp for a synchronization that never happened and waiting out an interval for a run nobody performed.
func (*DirectoryService) SyncNow ¶
func (s *DirectoryService) SyncNow(ctx context.Context, actor auth.Principal, sourceID string) (model.LDAPSyncRun, error)
SyncNow reads the directory and reconciles what it returns against the accounts this source owns.
The run record is opened before the directory is contacted and closed whatever happens, so a sync that dies mid-flight leaves evidence rather than nothing at all.
func (*DirectoryService) Update ¶
func (s *DirectoryService) Update(ctx context.Context, actor auth.Principal, id string, in LDAPSourceInput) (model.LDAPSource, error)
Update changes a directory's settings.
type EventPublisher ¶
type EventPublisher interface {
Publish(ctx context.Context, tenantID, eventType string, data any)
}
EventPublisher is the slice of the webhook service the account operations need.
An interface so that user.go states its dependency as the one method it calls, and so the two do not form a cycle when the webhook service comes to describe a user.
type ExternalIDP ¶
type ExternalIDP struct {
ID string `json:"id"`
Name string `json:"name"`
// Kind decides which fields the console shows: an issuer and scopes are
// meaningless for a provider whose endpoints are constants.
Kind string `json:"kind"`
ButtonLabel string `json:"buttonLabel"`
Issuer string `json:"issuer"`
ClientID string `json:"clientId"`
Scopes string `json:"scopes"`
TrustVerifiedEmail bool `json:"trustVerifiedEmail"`
Status string `json:"status"`
// HasSecret says whether one is stored, which is what an edit form needs
// in order to explain that leaving the field blank keeps it.
HasSecret bool `json:"hasSecret"`
// RedirectURI is what has to be registered at the other end. Returned
// rather than described, because it is the value somebody copies and a
// sentence about how it is composed is a sentence they have to compose
// correctly.
RedirectURI string `json:"redirectUri"`
}
ExternalIDP is one configured provider, as the console sees it.
There is no secret on it, by construction rather than by omission. What is stored is sealed and is only ever unsealed on the way to the provider; a field here would mean every list of providers carried every secret to a browser.
type ExternalIDPInput ¶
type ExternalIDPInput struct {
Name string
ButtonLabel string
// Kind is OIDC unless it is one of the two that need an adapter. Empty
// means OIDC, so every caller written before this existed still works.
Kind string
Issuer string
ClientID string
// ClientSecret empty on an edit means "keep the stored one". On a
// create it means a public client, which is why it is not required.
ClientSecret string
Scopes string
TrustVerifiedEmail bool
}
ExternalIDPInput is what an administrator supplies.
type ExternalIDPService ¶
type ExternalIDPService struct {
// contains filtered or unexported fields
}
ExternalIDPService owns the provider configuration.
func NewExternalIDPService ¶
func NewExternalIDPService(st *store.Store, users *UserService, audit *AuditService, vault *secrets.Vault, publicURL string) *ExternalIDPService
NewExternalIDPService builds it.
func (*ExternalIDPService) BoundCount ¶
BoundCount is how many accounts sign in through one.
What a delete confirmation has to say out loud: removing a provider unbinds everybody who arrived through it, and an account whose only way in was that button is an account that has lost it.
func (*ExternalIDPService) CompleteExternalSignIn ¶
func (s *ExternalIDPService) CompleteExternalSignIn(ctx context.Context, tenant model.Tenant, state, code, ip, userAgent string) (ExternalOutcome, error)
CompleteExternalSignIn judges a callback.
func (*ExternalIDPService) Create ¶
func (s *ExternalIDPService) Create(ctx context.Context, actor auth.Principal, tenantCode string, in ExternalIDPInput) (ExternalIDP, error)
Create registers one, after proving it exists.
The provider is contacted before the row is written. A configuration that cannot be discovered is one every sign-in through it will fail on, and the person able to fix it is the one filling in this form — not the user who meets the failure three days later at a login screen.
func (*ExternalIDPService) Get ¶
func (s *ExternalIDPService) Get(ctx context.Context, tenantID, tenantCode, id string) (ExternalIDP, error)
Get returns one.
func (*ExternalIDPService) IdentitiesFor ¶
func (s *ExternalIDPService) IdentitiesFor(ctx context.Context, tenantID, userID string) ([]ExternalIdentity, error)
IdentitiesFor lists what one account has bound.
func (*ExternalIDPService) List ¶
func (s *ExternalIDPService) List(ctx context.Context, tenantID, tenantCode string) ([]ExternalIDP, error)
List returns every provider configured for a tenant.
func (*ExternalIDPService) RedirectURI ¶
func (s *ExternalIDPService) RedirectURI(tenantCode string) string
RedirectURI is where a provider sends somebody back to.
Per tenant, because the client registration is per tenant: two tenants signing in through the same issuer are two different applications to it, with their own client ids and their own registered addresses.
A console address rather than the API endpoint that does the work. What arrives here is a top-level navigation — the browser leaves for the provider and comes back by following a redirect, so whatever answers has to be something a person can look at. The API endpoint answers JSON, and JSON is what a person would have been shown. So the console takes the landing, reads the `state` and `code` out of its own address, and spends them on the API call itself; the session that comes back is stored the same way a password sign-in's is, rather than travelling in a URL that browser history and every proxy in between would keep.
The tenant is in the path for the same reason it is in the sign-in screen's: the page has to know which tenant it is completing for, and it arrives without a header, without a cookie, and without anything else this deployment gave it.
func (*ExternalIDPService) SetStatus ¶
func (s *ExternalIDPService) SetStatus(ctx context.Context, actor auth.Principal, id string, status model.Status) error
SetStatus enables or disables one.
Disabling takes the button off the sign-in screen and leaves every binding in place, so switching it back on does not ask everybody to bind again. It is the control for "this provider is having an outage", which is the common case; deleting is for "we are not using them".
func (*ExternalIDPService) SignInOptions ¶
func (s *ExternalIDPService) SignInOptions(ctx context.Context, tenantID string) ([]SignInOption, error)
SignInOptions lists the buttons for a tenant.
func (*ExternalIDPService) StartExternalSignIn ¶
func (s *ExternalIDPService) StartExternalSignIn(ctx context.Context, tenant model.Tenant, providerID, userID string) (string, error)
StartExternalSignIn sends somebody to a provider.
userID empty is an ordinary sign-in. Set, it is a person already signed in asking to bind an identity to their own account, and the callback will land there whatever the provider says about who else it might be.
func (*ExternalIDPService) Unbind ¶
func (s *ExternalIDPService) Unbind(ctx context.Context, tenantID, userID, id string) error
Unbind removes one of an account's own identities.
No guard against removing the last one, unlike the last-administrator rules elsewhere. Every account here still has a password — external sign-in is an addition rather than a replacement in this version — so unbinding removes a convenience, not the only way in. The day a password-less account becomes possible, this needs the guard.
func (*ExternalIDPService) Update ¶
func (s *ExternalIDPService) Update(ctx context.Context, actor auth.Principal, tenantCode, id string, in ExternalIDPInput) (ExternalIDP, error)
Update edits one. An empty secret keeps the stored one.
The kind is not editable, and is taken from the stored row rather than from the request. Changing it would leave every identity already bound to this provider pointing at a protocol that did not issue them: the pair (issuer, subject) stays in the table while the meaning of both halves changes underneath it, and the first anybody would know is a person being told their account is not linked to anything. Replacing a provider is deleting it — which says how many bindings go with it — and creating another.
type ExternalIdentity ¶
type ExternalIdentity struct {
ID string `json:"id"`
ProviderID string `json:"providerId"`
ProviderName string `json:"providerName"`
Subject string `json:"subject"`
Email string `json:"email"`
CreatedAt time.Time `json:"createdAt"`
LastUsedAt *time.Time `json:"lastUsedAt"`
}
ExternalIdentity is one binding, as its owner sees it.
type ExternalOutcome ¶
type ExternalOutcome struct {
Session *Session
Bound *ExternalIdentity
}
ExternalOutcome is what a completed callback produced.
Exactly one of the two is set. A binding does not issue a session — the person already had one — and a sign-in does not report a binding, because the identity it used was bound long ago.
type Field ¶
type Field struct {
// Key is stable and is what a mapping stores. Never translated, never
// reused: a mapping that survives a rename would be a mapping that
// silently changed meaning.
Key string `json:"key"`
Group string `json:"group"`
Kind string `json:"kind"`
// Label is filled in for tenant-defined fields, whose name is whatever
// somebody typed. Empty for a built-in, whose label the console holds in
// its message catalogue under `fields.<key>` — a built-in has to read the
// same in both languages, and a stored string cannot do that.
Label string `json:"label,omitempty"`
// Custom distinguishes a tenant's own from the built-in set. The console
// needs it to know which ones can be edited, and the guard tests need it
// to know which ones must be documented.
Custom bool `json:"custom"`
// Inbound reports whether a directory may write this.
Inbound bool `json:"inbound"`
// OutboundOnlyBecause is the reason Inbound is false, and is required
// whenever it is. Several of these are security boundaries rather than
// omissions, and a reader of the list has to be able to tell which.
OutboundOnlyBecause string `json:"outboundOnlyBecause,omitempty"`
// AllowedValues constrains a SELECT. Empty otherwise.
AllowedValues []string `json:"allowedValues,omitempty"`
// Disabled marks a tenant-defined attribute that has been retired. Its
// values are kept and it is neither shown on a form nor sent, and it is
// listed rather than hidden so that it can be brought back.
Disabled bool `json:"disabled,omitempty"`
}
Field is one entry of the catalogue.
func BuiltInFields ¶
func BuiltInFields() []Field
BuiltInFields is the fixed half, for the tests that hold the documentation and the message catalogue in step with it. Copied rather than returned directly: a caller that sorted it in place would reorder every picker.
func (Field) Allows ¶
func (f Field) Allows(d MappingDirection) bool
Allows reports whether the field may be mapped in a direction.
type FieldCatalogue ¶
type FieldCatalogue struct {
// contains filtered or unexported fields
}
FieldCatalogue answers what may be mapped, for one tenant.
func NewFieldCatalogue ¶
func NewFieldCatalogue(st *store.Store) *FieldCatalogue
NewFieldCatalogue wires a catalogue.
func (*FieldCatalogue) CASAdditions ¶
func (c *FieldCatalogue) CASAdditions(ctx context.Context, tenantID string, user model.User, out Outbound) ([]CASAddition, error)
CASAdditions resolves the attributes a service has configured that the default response does not carry.
func (*FieldCatalogue) FieldValues ¶
func (c *FieldCatalogue) FieldValues(ctx context.Context, tenantID string, user model.User) (map[string]string, error)
FieldValues assembles every catalogue value this account has.
Keyed by catalogue key, so a mapping — which stores a key — can look up what to send without knowing where the value came from. The three sources are the account row, the organization it belongs to, and the tenant's own attributes.
func (*FieldCatalogue) Fields ¶
Fields returns the built-in vocabulary followed by the tenant's own.
Order is stable — built-ins in the order declared above, then custom ones by their sort order — because this list is drawn as a picker and a picker whose order changes between page loads is one nobody can build a habit with.
func (*FieldCatalogue) OIDCAdditions ¶
func (c *FieldCatalogue) OIDCAdditions(ctx context.Context, tenantID string, user model.User, out Outbound) (map[string]any, error)
OIDCAdditions resolves the claims a recipient has configured that the default set does not carry — which is most of the catalogue, and the larger half of what this feature is for.
Not gated by scope, and that is a decision rather than an oversight. Portico's own claims are already sent regardless of scope, so this is the file's existing precedent rather than a new rule; and a mapping configured for one application *is* the decision that this application receives this fact. A scope gate on top would mean a rule somebody configured silently doing nothing, which is the failure this whole feature exists to remove.
Renames and suppressions of scope-gated defaults stay gated, because they only fire where the default would have gone out anyway.
func (*FieldCatalogue) SAMLAdditions ¶
func (c *FieldCatalogue) SAMLAdditions(ctx context.Context, tenantID string, user model.User, out Outbound) ([]AddedAttribute, error)
SAMLAdditions resolves the attributes a service provider has configured that the default statement does not carry.
type FieldMappingInput ¶
type FieldMappingInput struct {
SourceKey string
TargetName string
FriendlyName string
// Suppressed removes a name the default would have sent. A flag rather than
// an empty target, because "send nothing" and "send under a name I have not
// chosen yet" are different intentions that one empty string cannot hold.
Suppressed bool
}
FieldMappingInput is one rule as an administrator describes it.
type FieldMappingService ¶
type FieldMappingService struct {
// contains filtered or unexported fields
}
FieldMappingService reads and writes what each application receives.
The defaults stay in the three protocol packages and are not rows here. An empty set means "behave exactly as before", which is what makes this feature safe to deploy: the upgrade changes nothing until somebody decides something.
func NewFieldMappingService ¶
func NewFieldMappingService(st *store.Store, audit *AuditService, catalogue *FieldCatalogue) *FieldMappingService
NewFieldMappingService wires the service.
func (*FieldMappingService) Mappings ¶
func (s *FieldMappingService) Mappings(ctx context.Context, tenantID string, ref store.RecipientRef) ([]model.FieldMapping, error)
Mappings returns one application's rules.
func (*FieldMappingService) OutboundFor ¶
func (s *FieldMappingService) OutboundFor(ctx context.Context, tenantID string, ref store.RecipientRef) (Outbound, error)
OutboundFor reads an application's rules.
An application with none gets an empty set, and every method below then leaves the defaults exactly as they were. That is the property the whole feature rests on: an upgrade changes nothing until somebody decides something.
func (*FieldMappingService) Recipient ¶
func (s *FieldMappingService) Recipient(ctx context.Context, tenantID string, kind RecipientKind, id string) (store.RecipientRef, error)
Recipient resolves an addressed recipient, confirming it exists in this tenant. The reference it returns is what every other method here takes.
func (*FieldMappingService) Replace ¶
func (s *FieldMappingService) Replace(ctx context.Context, actor auth.Principal, ref store.RecipientRef, inputs []FieldMappingInput) ([]model.FieldMapping, error)
Replace writes an application's whole set, replacing whatever was there.
A save is a table somebody edited, so it replaces rather than merges: merging would leave the rows the form deleted still in place, which is the one outcome nobody expects from a save.
Which names are refused depends on the recipient rather than on a flag the caller passes: OpenID Connect's registered claims mean nothing to a SAML service provider, where an attribute called `sub` is unremarkable.
type GroupInput ¶
GroupInput is what a caller supplies.
type GroupService ¶
type GroupService struct {
// contains filtered or unexported fields
}
GroupService owns groups and their membership.
Groups are not the organization chart, and the two are kept apart on purpose — see the schema comment on the groups table. In short: an organization is where somebody sits, one of them, in a tree; a group is a set they belong to, any number of them, flat, usually maintained by a directory.
Membership grants nothing. That is the same boundary the provisioning code holds for accounts: a directory says who somebody is, not what they may do.
func NewGroupService ¶
func NewGroupService(st *store.Store, audit *AuditService) *GroupService
NewGroupService wires a GroupService.
func (*GroupService) AddMembers ¶
func (s *GroupService) AddMembers(ctx context.Context, tenantID, groupID string, userIDs []string, actor auth.Principal) error
AddMembers puts accounts into a group.
A member that does not exist fails the whole call rather than being skipped. The composite foreign key catches an account from another tenant for free; this reports it instead of swallowing it, because a group that looks synchronized and quietly lost a member is the failure a directory cannot see from its own side.
func (*GroupService) Create ¶
func (s *GroupService) Create(ctx context.Context, tenantID string, in GroupInput, source model.GroupSource, actor auth.Principal) (model.Group, error)
Create adds a group.
func (*GroupService) FindByDisplayName ¶
func (s *GroupService) FindByDisplayName(ctx context.Context, tenantID, name string) (model.Group, error)
FindByDisplayName resolves a group by the name a directory pushes.
func (*GroupService) FindByExternalID ¶
func (s *GroupService) FindByExternalID(ctx context.Context, tenantID, externalID string) (model.Group, error)
FindByExternalID resolves the identifier a directory knows a group by.
func (*GroupService) GroupsForUser ¶
func (s *GroupService) GroupsForUser(ctx context.Context, tenantID, userID string) ([]model.GroupRef, error)
GroupsForUser returns the groups an account belongs to.
func (*GroupService) Members ¶
func (s *GroupService) Members(ctx context.Context, tenantID, groupID string) ([]model.GroupMember, error)
Members returns who is in a group.
func (*GroupService) RemoveMembers ¶
func (s *GroupService) RemoveMembers(ctx context.Context, tenantID, groupID string, userIDs []string, actor auth.Principal) error
RemoveMembers takes accounts out of a group.
func (*GroupService) ReplaceMembers ¶
func (s *GroupService) ReplaceMembers(ctx context.Context, tenantID, groupID string, userIDs []string, actor auth.Principal) error
ReplaceMembers sets a group's membership to exactly this list.
One transaction: a replacement that emptied the group and then failed halfway through refilling it would leave everybody out, which is the worst possible intermediate state for something that decides nothing but is read as if it does.
func (*GroupService) Update ¶
func (s *GroupService) Update(ctx context.Context, tenantID, id string, in GroupInput, actor auth.Principal) (model.Group, error)
Update changes a group's name, description, and external id.
func (*GroupService) WithEvents ¶
func (s *GroupService) WithEvents(publisher EventPublisher) *GroupService
WithEvents attaches a publisher, on the same terms as UserService's.
type ImportResult ¶
type ImportResult struct {
Total int `json:"total"`
Imported int `json:"imported"`
Failed int `json:"failed"`
Errors []ImportRowError `json:"errors"`
}
ImportResult summarizes an upload.
type ImportRowError ¶
type ImportRowError struct {
// Row is the 1-based row number in the spreadsheet, including the
// header, so it matches what the user sees in Excel.
Row int `json:"row"`
Username string `json:"username"`
Code string `json:"code"`
Message string `json:"message"`
}
ImportRowError is one row that could not be imported.
type IssuedSCIMCredential ¶
type IssuedSCIMCredential struct {
SCIMCredential
// Token is present on creation and never again. It is not stored, so
// there is nothing to return later and nothing for a database dump to
// leak.
Token string `json:"token"`
}
IssuedSCIMCredential is what creation returns, once.
type IssuedTicket ¶
type IssuedTicket struct {
Ticket string
// RedirectTo is the service URL with the ticket appended, which is what
// CAS expects the browser to be sent to.
RedirectTo string
// ServiceName is what the sign-in screen shows.
ServiceName string
}
IssuedTicket is a service ticket and where to send the browser with it.
type LDAPSourceInput ¶
type LDAPSourceInput struct {
Name string
Host string
Port int
Encryption string
BindDN string
// BindPassword is applied only when non-nil. A nil pointer means "leave
// what is stored alone", which is what an edit form that cannot display
// the current value has to be able to express — otherwise submitting it
// unchanged would blank the credential.
BindPassword *string
BaseDN string
UserFilter string
AttrUsername string
AttrDisplayName string
AttrEmail string
AttrPhone string
AttrExternalID string
OrganizationID string
// SyncIntervalMinutes is how often to synchronize without being asked.
// Zero is off, and is the default for a directory registered without
// mentioning it.
SyncIntervalMinutes int
}
LDAPSourceInput is a directory as an administrator describes it.
type Logo ¶
type Logo struct {
ID string
ContentType string
Bytes []byte
// SHA256 is the ETag. Stored rather than computed on read: hashing the
// body to answer a conditional request would defeat the point of one.
SHA256 string
}
Logo is a stored picture, ready to be written to a response.
type MappingDirection ¶
type MappingDirection string
MappingDirection is which way an entry may be mapped.
const ( // DirectionOutbound is Portico → an application's field name. DirectionOutbound MappingDirection = "OUTBOUND" // DirectionInbound is a directory attribute → Portico. DirectionInbound MappingDirection = "INBOUND" )
type OAuthClientService ¶
type OAuthClientService struct {
// contains filtered or unexported fields
}
OAuthClientService owns the registered relying parties.
A registration decides who may ask this server for tokens about a tenant's users, so it is an administrative act of real weight — but it is one a tenant administrator is already trusted with. They can reset any password in their own tenant, which is strictly more power than registering an application, and registration is tenant-scoped, so it grants nothing across the boundary. It is therefore available over the API to an administrator, as well as from the command line, and every mutation is audited.
Dynamic client registration (RFC 7591) is a different question and remains deliberately absent: that is registration by an anonymous caller, with no administrator in the loop at all.
func NewOAuthClientService ¶
func NewOAuthClientService(st *store.Store, audit *AuditService) *OAuthClientService
NewOAuthClientService wires an OAuthClientService.
func (*OAuthClientService) Get ¶
func (s *OAuthClientService) Get(ctx context.Context, tenantID, clientID string) (model.OAuthClient, error)
Get returns one relying party.
func (*OAuthClientService) List ¶
func (s *OAuthClientService) List(ctx context.Context, tenantID string) ([]model.OAuthClient, error)
List returns every relying party in a tenant.
func (*OAuthClientService) Register ¶
func (s *OAuthClientService) Register(ctx context.Context, actor auth.Principal, in RegisterClientInput) (RegisteredClient, error)
Register adds a relying party to the actor's tenant.
func (*OAuthClientService) RotateSecret ¶
func (s *OAuthClientService) RotateSecret(ctx context.Context, actor auth.Principal, clientID string) (RegisteredClient, error)
RotateSecret issues a confidential client a new secret and invalidates the old one immediately.
There is no overlap period in which both work. That would be kinder to a running deployment, but the reason to rotate is usually that the old secret leaked, and a rotation that leaves the leaked value working is not a rotation. An operator who is rotating on a schedule instead can register a second client and retire the first.
func (*OAuthClientService) SetStatus ¶
func (s *OAuthClientService) SetStatus(ctx context.Context, actor auth.Principal, clientID string, status model.Status) (model.OAuthClient, error)
SetStatus enables or disables a relying party. A disabled client's authorization requests are refused and its tokens stop refreshing, without anything being deleted.
func (*OAuthClientService) Update ¶
func (s *OAuthClientService) Update(ctx context.Context, actor auth.Principal, clientID string, in UpdateClientInput) (model.OAuthClient, error)
Update changes a relying party's settings.
func (*OAuthClientService) VerifySecret ¶
func (s *OAuthClientService) VerifySecret(ctx context.Context, tenantID, clientID, secret string) error
VerifySecret checks a confidential client's credentials.
type OrganizationInput ¶
type OrganizationInput struct {
Name string
Code string
Remark string
// ParentID is empty for a root. On an update it is the move: a
// different value reparents, an empty one promotes to a root.
ParentID string
SortOrder int
}
OrganizationInput is the writable part of an organization.
type OrganizationService ¶
type OrganizationService struct {
// contains filtered or unexported fields
}
OrganizationService owns the organization tree and membership.
func NewOrganizationService ¶
func NewOrganizationService(st *store.Store, audit *AuditService) *OrganizationService
NewOrganizationService wires an OrganizationService.
func (*OrganizationService) AdministeredOrganizations ¶
func (s *OrganizationService) AdministeredOrganizations(ctx context.Context, tenantID, userID string) ([]model.AdministeredOrganization, error)
AdministeredOrganizations returns what somebody is recorded as administering. This is the query delegated administration will make on every request; today it draws a list on a screen.
func (*OrganizationService) Administrators ¶
func (s *OrganizationService) Administrators(ctx context.Context, tenantID, organizationID string) ([]model.OrganizationAdministrator, error)
Administrators returns who is recorded as administering an organization.
func (*OrganizationService) AssignAdministrator ¶
func (s *OrganizationService) AssignAdministrator(ctx context.Context, actor auth.Principal, organizationID, userID, scope string) error
AssignAdministrator records that somebody would administer an organization.
It grants nothing, and the rest of this system does not consult it. The rows exist because delegated administration is planned and an organization chart is entered by people over months: a feature that arrives to an empty table makes every customer re-enter what they already said. See migration 00020.
Changing a scope is a remove and an add rather than an update, so that both appear in the audit trail as the decisions they are. An assignment that could be edited in place would leave "who widened this, and when" unanswerable.
func (*OrganizationService) AttachUser ¶
func (s *OrganizationService) AttachUser(ctx context.Context, actor auth.Principal, organizationID, userID string) error
AttachUser adds an advisory attachment between a person and an organization they are involved with but do not primarily belong to.
It does not touch their primary membership, and it grants nothing — the same as group membership, and for the same reason: there is no permission model here for it to attach to.
func (*OrganizationService) Attachments ¶
func (s *OrganizationService) Attachments(ctx context.Context, tenantID, userID string) ([]model.OrganizationRef, error)
Attachments returns the organizations a person is attached to.
func (*OrganizationService) Create ¶
func (s *OrganizationService) Create(ctx context.Context, actor auth.Principal, in OrganizationInput) (model.Organization, error)
Create adds an organization to the actor's tenant.
func (*OrganizationService) DetachUser ¶
func (s *OrganizationService) DetachUser(ctx context.Context, actor auth.Principal, organizationID, userID string) error
DetachUser removes an attachment. Idempotent: removing one that is not there is not an error, because a caller reconciling a list should not have to know what is already gone.
func (*OrganizationService) Get ¶
func (s *OrganizationService) Get(ctx context.Context, tenantID, id string) (model.Organization, error)
Get returns one organization.
func (*OrganizationService) List ¶
func (s *OrganizationService) List(ctx context.Context, tenantID string, activeOnly bool) ([]model.Organization, error)
List returns every organization in the tenant in display order, with member counts.
Flat on the wire, each row naming its parent; the tree is assembled for display. There is no pagination, and that is not a size assumption: a page boundary drawn through a tree separates children from their parent and leaves something that is neither a tree nor a list.
func (*OrganizationService) RevokeAdministrator ¶
func (s *OrganizationService) RevokeAdministrator(ctx context.Context, actor auth.Principal, organizationID, userID string) error
RevokeAdministrator removes an assignment. Idempotent, on the same terms as detaching: a caller reconciling a list should not have to know what is already gone.
func (*OrganizationService) SetManager ¶
func (s *OrganizationService) SetManager(ctx context.Context, actor auth.Principal, organizationID, managerID string) (model.Organization, error)
SetManager nominates whoever is responsible for an organization, or clears the nomination with an empty id.
It grants nothing, which is worth restating at the point somebody might expect otherwise: being named here does not let the person administer the organization, edit its members, or do anything an ordinary account cannot. This version has two fixed roles. A field that quietly became a third would be a permission model nobody designed and nobody could audit.
func (*OrganizationService) SetStatus ¶
func (s *OrganizationService) SetStatus(ctx context.Context, actor auth.Principal, id string, status model.Status) (model.Organization, error)
SetStatus enables or disables an organization.
Disabling keeps existing members in place and only blocks new assignments (§3.4.1). Members are deliberately not detached: doing so would silently erase the record of who belonged where.
func (*OrganizationService) Update ¶
func (s *OrganizationService) Update(ctx context.Context, actor auth.Principal, id string, in OrganizationInput) (model.Organization, error)
Update changes an organization's name, remark, parent, and ordering.
The code is immutable: downstream systems may have stored it, and letting it change would silently break those references. The parent is not — an organization chart is exactly the thing that gets rearranged — but a move that would put an organization inside its own subtree is refused.
func (*OrganizationService) WithEvents ¶
func (s *OrganizationService) WithEvents(publisher EventPublisher) *OrganizationService
WithEvents attaches a publisher, on the same terms as UserService's.
type Outbound ¶
type Outbound struct {
// contains filtered or unexported fields
}
Outbound is what a protocol package asks for: the rules, indexed by source key, ready to apply over the defaults.
Returned as a type rather than a map so that the two operations a caller needs — "has this default been renamed or suppressed" and "what else should I add" — are named rather than open-coded at three call sites that would drift.
func (Outbound) Additions ¶
func (o Outbound) Additions(defaults map[string]bool) []model.FieldMapping
Additions are the rules for fields the defaults do not send at all — which is most of the catalogue, and the larger half of what this feature is for.
type Page ¶
Page is the limit/offset pair a list query runs with. Handlers translate the API's page/pageSize into this.
type PasswordPolicy ¶
type PasswordPolicy struct {
MinLength int
RequireUppercase bool
RequireLowercase bool
RequireDigit bool
RequireSymbol bool
// HistoryDepth is how many previous passwords may not be reused. Zero
// means reuse is not checked.
HistoryDepth int
// MaxAgeDays is how long a password stays usable. Zero means forever.
MaxAgeDays int
}
PasswordPolicy is a tenant's rules, derived from its settings.
func (PasswordPolicy) Check ¶
func (p PasswordPolicy) Check(plaintext string) error
Check applies the composition rules.
It reports every unmet rule at once rather than the first. A form that says "needs a digit", then "needs a symbol", then "too short" on three successive attempts is the interaction that makes people give up and reuse something.
func (PasswordPolicy) Expired ¶
Expired reports whether a password set at changedAt is past its life.
A nil changedAt means the password has never been changed since the account was created, which counts as due. Treating unknown as fresh would exempt exactly the accounts an expiry policy is turned on to catch — imported ones, and ones created with a password somebody dictated.
func (PasswordPolicy) MaxAge ¶
func (p PasswordPolicy) MaxAge() time.Duration
MaxAge is the expiry interval, or zero if passwords do not expire.
type ProfileInput ¶
ProfileInput is what a user may change about themselves.
The absent fields are the point. Username is immutable, because downstream systems match on it and because it is a sign-in identifier. Role, status, and organization are administrative decisions — a self-service endpoint that accepted a role would be a privilege-escalation endpoint, and one that accepted an organization would let anyone file themselves under any department.
type ProvisionUserInput ¶
type ProvisionUserInput struct {
Username string
DisplayName string
Email string
Phone string
ExternalID string
Active bool
// Profile is the descriptive half, which a directory sends alongside
// everything else. Written through the same statement the console uses,
// so there is one place these attributes are validated.
Profile model.UserProfile
}
ProvisionUserInput is what a directory supplies.
No password: a provisioning system pushing one would mean the directory holds a value it can replay, and this deployment's own policy would apply to something nobody here chose. No role: SCIM has no notion of Portico's two roles, and inventing a mapping would let a directory grant administrator by writing an attribute. No organization, for the same reason group provisioning is absent.
type RecipientKind ¶
type RecipientKind string
RecipientKind is which of the four a path segment is addressing.
const ( RecipientOAuthClient RecipientKind = "OAUTH_CLIENT" RecipientSAMLProvider RecipientKind = "SAML_SERVICE_PROVIDER" RecipientCASService RecipientKind = "CAS_SERVICE" RecipientWebhook RecipientKind = "WEBHOOK_SUBSCRIPTION" )
The four, which differ only in which table the id is looked up in.
type RecoveryService ¶
type RecoveryService struct {
// contains filtered or unexported fields
}
RecoveryService issues and redeems password-reset tokens (§3.5).
func NewRecoveryService ¶
func NewRecoveryService( st *store.Store, users *UserService, audit *AuditService, settings *SettingsService, mailer notify.Mailer, sms notify.SMSSender, publicURL string, ) *RecoveryService
NewRecoveryService wires a RecoveryService.
func (*RecoveryService) AvailableChannels ¶
func (s *RecoveryService) AvailableChannels() []model.RecoveryChannel
AvailableChannels reports which recovery channels this deployment can actually use, so the sign-in screen offers only those.
func (*RecoveryService) Confirm ¶
func (s *RecoveryService) Confirm(ctx context.Context, tenantID, token, newPassword, ip string) error
Confirm redeems a reset token and sets a new password.
Every way of failing — unknown token, spent token, expired token — returns the same error. Distinguishing them would let someone with a stolen but expired link learn that it was once real, and there is nothing a legitimate user does differently in the three cases: they request another.
func (*RecoveryService) RecoverySentToday ¶
func (s *RecoveryService) RecoverySentToday(ctx context.Context, tenantID, userID string) (int, error)
RecoverySentToday reports how many reset messages an account has been sent inside its current window, which is what an administrator needs in order to tell "they are not receiving our mail" from "we stopped sending it".
Not on the account list. It costs a query per account, and the question is asked while looking at one person — the same reasoning that keeps organization attachments off the list.
func (*RecoveryService) Request ¶
func (s *RecoveryService) Request(ctx context.Context, tenant model.Tenant, channel model.RecoveryChannel, destination, ip string) error
Request starts password recovery for whoever holds destination in tenant.
It returns nil whether or not an account was found. Reporting the difference would turn this endpoint into an oracle for "does this person have an account here", which for an identity server is a disclosure in its own right — and the same neutrality has to hold for all three misses: no such account, an account with nothing bound on that channel, and a successful send. The only failure a caller sees is the deployment having no provider at all, which is about the deployment rather than about them.
The account is resolved against the channel's own column. Sign-in resolves an identifier across all three, and reusing that here would be an account takeover: if one account's email equals another's username, the username holder wins the union lookup, and a reset token for their account would be sent to whoever typed that address.
type RegisterCASInput ¶
type RegisterCASInput struct {
Name string
// URLPrefix is what a service parameter must begin with.
URLPrefix string
// LaunchURL is optional: an application without one still signs people
// in, it just does not appear in the portal as something to open.
LaunchURL string
// LogoURI is optional: without one the portal tile carries the first
// character of the name.
LogoURI string
}
RegisterCASInput describes a service to register.
type RegisterClientInput ¶
type RegisterClientInput struct {
ClientID string
Name string
// Public marks a client that cannot keep a secret — a browser or mobile
// application. It gets no secret and authenticates with PKCE alone.
Public bool
ApplicationType string
RedirectURIs []string
PostLogoutRedirectURIs []string
Scopes []string
// LaunchURL is optional: an application without one still signs people
// in, it just does not appear in the portal as something to open.
LaunchURL string
// LogoURI is optional: without one the portal tile carries the first
// character of the name.
LogoURI string
}
RegisterClientInput describes a relying party to register.
type RegisterInput ¶
type RegisterInput struct {
Username string
DisplayName string
Password string
Phone string
Email string
}
RegisterInput is a self-service sign-up.
type RegisterSPInput ¶
type RegisterSPInput struct {
// MetadataXML is the service provider's metadata document.
MetadataXML string
// Name is what an operator calls it. Defaults to the entity id.
Name string
// LaunchURL is optional: an application without one still signs people
// in, it just does not appear in the portal as something to open.
LaunchURL string
// LogoURI is optional: without one the portal tile carries the first
// character of the name.
LogoURI string
}
RegisterSPInput describes a service provider to register.
The metadata document is the registration. Everything the protocol needs — the entity id, the assertion consumer service endpoints, the NameID formats a service provider will accept, its signing certificate — is in there, published by the service provider itself, and asking an operator to retype any of it is asking them to get it subtly wrong.
type RegisteredClient ¶
type RegisteredClient struct {
Client model.OAuthClient
// Secret is empty for a public client, and for a confidential one is the
// only time the value exists outside the caller's terminal: what is
// stored is a hash.
Secret string
}
RegisteredClient is a client together with the secret generated for it, which is available exactly once.
type SAMLAttribute ¶
SAMLAttribute is the pair of names one catalogue key goes out under.
func AttributeFor ¶
func AttributeFor(out Outbound, key string) (attr SAMLAttribute, send, aliased bool)
AttributeFor decides one default attribute's fate under a recipient's rules.
aliased reports that the display name's second attribute — `cn` — should still be sent, which is true only when nothing renamed or suppressed it.
type SAMLKey ¶
type SAMLKey struct {
ID string
Private *rsa.PrivateKey
Certificate *x509.Certificate
// CertificatePEM is what an operator hands to a service provider that
// wants the certificate rather than the metadata document.
CertificatePEM string
CreatedAt time.Time
ExpiresAt time.Time
Retired bool
}
SAMLKey is a signing key with its certificate, both parsed.
type SAMLKeyService ¶
type SAMLKeyService struct {
// contains filtered or unexported fields
}
SAMLKeyService owns the certificates that sign SAML assertions.
Separate from SigningKeyService, whose keys the JWKS publishes, because the two have incompatible rotation contracts rather than merely different callers. A relying party refetches a key set, so an OIDC key can be retired and deleted a day later without anybody noticing. A SAML service provider is configured with the certificate and has no way to learn of a new one — so retired certificates are kept indefinitely, and rotating is something an operator does while moving service providers across, not something that happens on a timer.
func NewSAMLKeyService ¶
func NewSAMLKeyService(st *store.Store) *SAMLKeyService
NewSAMLKeyService wires a SAMLKeyService.
func (*SAMLKeyService) Active ¶
Active returns the key assertions are signed with, generating one on first use.
func (*SAMLKeyService) Rotate ¶
Rotate retires the current certificate and generates a replacement.
Nothing is deleted. Every service provider has to be reconfigured with the new certificate by hand, and until each one has been, the old certificate is what the operator needs to be able to show. Deleting it would make "which certificate is that integration still pinning" unanswerable at the moment it is being asked.
type SAMLServiceProviderService ¶
type SAMLServiceProviderService struct {
// contains filtered or unexported fields
}
SAMLServiceProviderService owns the registered SAML service providers.
A registration decides who may receive assertions about this tenant's people, which is the same weight of decision as registering an OAuth relying party and is available on the same terms: a tenant administrator, over the API or from the command line, with every mutation audited. See OAuthClientService for why that is the right boundary.
func NewSAMLServiceProviderService ¶
func NewSAMLServiceProviderService(st *store.Store, audit *AuditService) *SAMLServiceProviderService
NewSAMLServiceProviderService wires the service.
func (*SAMLServiceProviderService) Descriptor ¶
func (s *SAMLServiceProviderService) Descriptor(ctx context.Context, tenantID, entityID string) (*saml.EntityDescriptor, error)
Descriptor returns the parsed metadata the protocol library works from.
func (*SAMLServiceProviderService) Get ¶
func (s *SAMLServiceProviderService) Get(ctx context.Context, tenantID, entityID string) (model.SAMLServiceProvider, error)
Get returns one service provider.
func (*SAMLServiceProviderService) GetByID ¶
func (s *SAMLServiceProviderService) GetByID(ctx context.Context, tenantID, id string) (model.SAMLServiceProvider, error)
GetByID returns one service provider by the registration's own id.
The console addresses registrations this way rather than by entity id. An entity id is a URI, so putting one in a URL path means percent-encoding its slashes — and a reverse proxy that normalizes paths decodes them again, splitting the identifier across segments. That failure depends on somebody else's proxy configuration and would never show up in a test here, so the identifier is one that has no slashes to begin with.
func (*SAMLServiceProviderService) List ¶
func (s *SAMLServiceProviderService) List(ctx context.Context, tenantID string) ([]model.SAMLServiceProvider, error)
List returns every service provider in a tenant.
func (*SAMLServiceProviderService) Register ¶
func (s *SAMLServiceProviderService) Register(ctx context.Context, actor auth.Principal, in RegisterSPInput) (model.SAMLServiceProvider, error)
Register adds a service provider to the actor's tenant.
func (*SAMLServiceProviderService) SetStatus ¶
func (s *SAMLServiceProviderService) SetStatus(ctx context.Context, actor auth.Principal, entityID string, status model.Status) (model.SAMLServiceProvider, error)
SetStatus enables or disables a service provider.
func (*SAMLServiceProviderService) Update ¶
func (s *SAMLServiceProviderService) Update(ctx context.Context, actor auth.Principal, entityID string, in UpdateSPInput) (model.SAMLServiceProvider, error)
Update replaces a service provider's name and metadata.
type SCIMCredential ¶
type SCIMCredential struct {
ID string `json:"id"`
Name string `json:"name"`
TokenPrefix string `json:"tokenPrefix"`
Status string `json:"status"`
LastUsedAt *time.Time `json:"lastUsedAt"`
CreatedAt time.Time `json:"createdAt"`
}
SCIMCredential is a credential as the console sees it: never the token.
type SCIMCredentialService ¶
type SCIMCredentialService struct {
// contains filtered or unexported fields
}
SCIMCredentialService issues and resolves the tokens a provisioning system authenticates with.
A SCIM client is not a person and does not get an account. It has no session, no password to recover, no organization, and no way to reach the console — see the schema comment on scim_credentials for why modelling it as a user would be a standing invitation to a listing that forgot to exclude it.
func NewSCIMCredentialService ¶
func NewSCIMCredentialService(st *store.Store, audit *AuditService) *SCIMCredentialService
NewSCIMCredentialService wires a SCIMCredentialService.
func (*SCIMCredentialService) Authenticate ¶
func (s *SCIMCredentialService) Authenticate(ctx context.Context, token string) (SCIMPrincipal, error)
Authenticate resolves a bearer token to the tenant it acts in.
This is the query that determines the tenant, so it cannot be scoped to one. Everything the request goes on to do is scoped to what this returned.
func (*SCIMCredentialService) Create ¶
func (s *SCIMCredentialService) Create(ctx context.Context, actor auth.Principal, name string) (IssuedSCIMCredential, error)
Create issues a credential and returns the only copy of its token.
func (*SCIMCredentialService) List ¶
func (s *SCIMCredentialService) List(ctx context.Context, tenantID string) ([]SCIMCredential, error)
List returns a tenant's credentials, without tokens.
type SCIMPrincipal ¶
SCIMPrincipal is what a resolved credential authorizes: a tenant, and nothing else. There is no role and no subject, because a provisioning client acts as itself and its reach is decided by the routes the token is accepted on.
type Session ¶
type Session struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expiresAt"`
User model.User `json:"user"`
}
Session is what a successful login returns.
type SessionService ¶
type SessionService struct {
// contains filtered or unexported fields
}
SessionService reads and ends individual sign-ins.
Portico's own credential is still a JWT, and this does not turn it into a server-side session in the usual sense: the token is self-describing and is not stored. What the table adds is a name for each sign-in, so that "sign out" can mean this one rather than all of them, and so that somebody can look at what is signed in as them and end the one they do not recognize.
The cost is a second read per authenticated request. It is small because the middleware was already reading the account on every request — that is what makes a disable take effect immediately — so this rides along on a round trip that was happening anyway.
func NewSessionService ¶
func NewSessionService(st *store.Store, audit *AuditService) *SessionService
NewSessionService wires the service.
func (*SessionService) CheckSession ¶
func (s *SessionService) CheckSession(ctx context.Context, tenantID, sessionID string) error
CheckSession implements auth.SessionLookup: it reports whether the session a token names is still live, and records that it was used.
func (*SessionService) List ¶
func (s *SessionService) List(ctx context.Context, tenantID, userID, currentSessionID string) ([]model.Session, error)
List returns an account's live sessions, most recently used first.
func (*SessionService) Revoke ¶
func (s *SessionService) Revoke(ctx context.Context, actor auth.Principal, userID, sessionID string) error
Revoke ends one session belonging to userID.
The owner is a parameter rather than taken from the session row, so that a caller can only ever end a session they were entitled to name: their own, or — for an administrator — one belonging to the account they asked about. Without it, a session id from anywhere would do.
func (*SessionService) RevokeAllForUser ¶
func (s *SessionService) RevokeAllForUser(ctx context.Context, tenantID, userID string) error
RevokeAllForUser ends every session an account holds.
type Settings ¶
type Settings struct {
// TokenTTLMinutes is the console's own session, not the OIDC tokens.
TokenTTLMinutes int `json:"tokenTtlMinutes"`
// The three OIDC lifetimes. Days rather than minutes for the second and
// third because minutes for a thirty-day value is a field nobody can read
// at a glance — 43200 and 432000 differ by a digit and by a factor of ten.
OIDCAccessTokenTTLMinutes int `json:"oidcAccessTokenTtlMinutes"`
OIDCRefreshTokenTTLDays int `json:"oidcRefreshTokenTtlDays"`
// OIDCSessionMaxAgeDays caps the whole refresh chain. Zero means no cap.
OIDCSessionMaxAgeDays int `json:"oidcSessionMaxAgeDays"`
RegistrationEnabled bool `json:"registrationEnabled"`
// ShowGuides offers the explanatory panel on each administrative screen.
ShowGuides bool `json:"showGuides"`
// RegistrationVerification requires a self-registered account to prove
// its email address or phone number before it can sign in. Without it
// somebody can open an account under a colleague's address — and that
// address is where a password-reset link would be sent.
RegistrationVerification bool `json:"registrationVerification"`
SystemName string `json:"systemName"`
// LockoutThreshold is the number of consecutive failed sign-ins that
// locks an account. Zero means no lockout.
LockoutThreshold int `json:"lockoutThreshold"`
// LockoutDurationMinutes is how long the lock lasts.
LockoutDurationMinutes int `json:"lockoutDurationMinutes"`
PasswordMinLength int `json:"passwordMinLength"`
PasswordRequireUppercase bool `json:"passwordRequireUppercase"`
PasswordRequireLowercase bool `json:"passwordRequireLowercase"`
PasswordRequireDigit bool `json:"passwordRequireDigit"`
PasswordRequireSymbol bool `json:"passwordRequireSymbol"`
// PasswordHistoryDepth is how many previous passwords may not be
// reused. Zero does not check.
PasswordHistoryDepth int `json:"passwordHistoryDepth"`
// PasswordMaxAgeDays is how long a password stays usable. Zero never
// expires.
PasswordMaxAgeDays int `json:"passwordMaxAgeDays"`
// DefaultLocale is the language of messages sent to somebody in this
// tenant who has stated no preference. Empty follows the deployment.
//
// It does not affect the console: a reader picks that for themselves and
// it is remembered in their browser. This is for the text that arrives
// where there is no menu — a reset link, a confirmation.
DefaultLocale string `json:"defaultLocale"`
// AuditRetentionDays is how long audit entries are kept before the
// periodic sweep removes them. Zero keeps them forever, which is the
// default and the only safe one to ship: the trail is the record of
// what happened, and a product that quietly started deleting it on a
// timer would be doing the worst thing an audit log can do.
AuditRetentionDays int `json:"auditRetentionDays"`
}
Settings is the full set of runtime settings for one tenant.
func (Settings) LockoutDuration ¶
LockoutDuration is the lock length as a duration. It doubles as the window failures are counted over, so that "five failures in fifteen minutes locks for fifteen minutes" is one number rather than two that have to be kept in a sensible relationship.
func (Settings) LockoutEnabled ¶
LockoutEnabled reports whether this tenant locks accounts at all.
func (Settings) OIDCAccessTokenLifetime ¶
OIDCAccessTokenLifetime is how long an access token this server issues stays valid. The ID token gets the same, deliberately; see SettingOIDCAccessTokenTTLMinutes.
func (Settings) OIDCRefreshTokenLifetime ¶
OIDCRefreshTokenLifetime is how long a refresh token stays usable before it has to be exchanged. Rotation resets it.
func (Settings) OIDCSessionCapped ¶
OIDCSessionCapped reports whether this tenant ends refresh chains by age at all. Named rather than left as a `> 0` at each use, because the two places that ask are a signing path and a UI hint and they must agree.
func (Settings) OIDCSessionMaxAge ¶
OIDCSessionMaxAge is the absolute age a refresh chain may reach, counted from the sign-in that began it. Zero means no cap, which is what a caller has to check for: passing it to a comparison unchecked would expire every session immediately.
func (Settings) PasswordPolicy ¶
func (s Settings) PasswordPolicy() PasswordPolicy
PasswordPolicy is the password half of these settings.
type SettingsService ¶
type SettingsService struct {
// contains filtered or unexported fields
}
SettingsService reads and writes runtime settings, caching them in memory because they are read on every login and change rarely.
The cache is keyed by tenant. Settings are per-tenant — one tenant may accept sign-ups while another does not, and each names itself — so a single cached value would serve one tenant's configuration to another.
func NewSettingsService ¶
func NewSettingsService(st *store.Store, defaultTokenTTL time.Duration) *SettingsService
NewSettingsService returns a service whose defaults come from the process configuration, so an operator can set a starting value via environment variable and adjust it later from the UI.
func (*SettingsService) CanDeliver ¶
func (s *SettingsService) CanDeliver() bool
CanDeliver reports whether any channel is configured.
func (*SettingsService) Defaults ¶
func (s *SettingsService) Defaults() Settings
Defaults returns the settings a tenant has before anybody changes any of them.
Exposed for one purpose: a caller that needs a lifetime on a path where failing is worse than being slightly wrong. Signing would otherwise have to take a whole tenant's sign-in down over an unreadable settings row, when the values it wanted are constants that most deployments never touch.
func (*SettingsService) Get ¶
Get returns a tenant's current settings, reading from the database on first use.
func (*SettingsService) MessageLocale ¶
func (s *SettingsService) MessageLocale(ctx context.Context, tenantID, accountPreference string) i18n.Locale
MessageLocale picks the language for a message Portico sends to somebody.
One function so the chain exists once. Both mailers ask this rather than each resolving for itself, because two implementations of "which language" is how a confirmation arrives in one language and the reset link that follows it arrives in another.
A tenant whose settings cannot be read falls back rather than failing: a person waiting for a reset link should get one in English, not nothing.
func (*SettingsService) RegistrationEnabled ¶
RegistrationEnabled is a convenience read used by the registration path.
func (*SettingsService) Update ¶
func (s *SettingsService) Update(ctx context.Context, tenantID string, next Settings) (Settings, error)
Update writes a tenant's settings and refreshes its cache entry.
func (*SettingsService) WithDefaultLocale ¶
func (s *SettingsService) WithDefaultLocale(locale string)
WithDefaultLocale tells the settings service what the deployment was configured with, which is the last stop before English.
Attached after construction for the same reason WithDeliveryChannels is: it comes from process configuration rather than from anything this service can work out, and every test that builds a settings service should not have to know about it.
func (*SettingsService) WithDeliveryChannels ¶
func (s *SettingsService) WithDeliveryChannels(channels func() []model.RecoveryChannel)
WithDeliveryChannels tells the settings service what this deployment can send, so it can refuse a setting that depends on being able to.
type SignInOption ¶
SignInOption is one button on the sign-in screen.
A label and an id, and nothing else. What a button says is public the moment it is drawn; the issuer, the client id and whether an address is trusted are not, and this endpoint answers before anybody has proved anything.
type SigningKey ¶
type SigningKey struct {
ID string
Algorithm string
Private *rsa.PrivateKey
CreatedAt time.Time
Retired bool
}
SigningKey is a key with its private half parsed.
type SigningKeyService ¶
type SigningKeyService struct {
// contains filtered or unexported fields
}
SigningKeyService owns the asymmetric keys that sign ID tokens.
These exist separately from the HS256 secret that signs Portico's own sessions, and the difference is not incidental. A session token is verified by this server, which can keep a secret. An ID token is verified by somebody else, offline, against a published key set — so it must be signed by a key whose public half can be given out, and the private half can never leave.
Keys are per tenant because issuers are per tenant. A relying party configured for one tenant fetches that tenant's key set and will not verify a token signed for another, which is what makes cross-tenant token confusion impossible rather than merely unlikely.
func NewSigningKeyService ¶
func NewSigningKeyService(st *store.Store) *SigningKeyService
NewSigningKeyService wires a SigningKeyService.
func (*SigningKeyService) Active ¶
func (s *SigningKeyService) Active(ctx context.Context, tenantID string) (SigningKey, error)
Active returns the key new tokens are signed with, generating one if the tenant has none.
Generating on demand rather than at tenant creation means an existing deployment gains federation by upgrading, without a migration step that backfills keys — and a tenant that never issues a token never pays the cost of an RSA keygen.
func (*SigningKeyService) Published ¶
func (s *SigningKeyService) Published(ctx context.Context, tenantID string) ([]SigningKey, error)
Published returns every key the JWKS should advertise: the active one and any retired key whose tokens may still be in flight.
func (*SigningKeyService) Rotate ¶
func (s *SigningKeyService) Rotate(ctx context.Context, tenantID string) (SigningKey, error)
Rotate retires the current key and generates a replacement.
The old key stays in the key set for SigningKeyRetention, so tokens signed a moment before the rotation keep verifying. Anything retired longer than that is dropped in the same pass — a key set that only grows is a key set nobody prunes.
type SnapshotSource ¶
type SnapshotSource interface {
ListUsers(ctx context.Context, tenantID string, q UserQuery, page Page) ([]model.User, int64, error)
ListOrganizations(ctx context.Context, tenantID string, activeOnly bool) ([]model.Organization, error)
ListGroups(ctx context.Context, tenantID string) ([]model.Group, error)
}
SnapshotSource is what a snapshot reads.
An interface rather than the three services, and attached after construction rather than taken by the constructor, because the webhook service is built before them — the same arrangement as WithEvents and WithFieldMappings. It also keeps the dependency one-way: accounts publish events, and nothing in the account service needs to know a snapshot exists.
func NewSnapshotSource ¶
func NewSnapshotSource(users *UserService, orgs *OrganizationService, groups *GroupService) SnapshotSource
NewSnapshotSource adapts the three services a snapshot reads.
An adapter rather than making the services implement the interface directly: their List methods are named for their own package, and renaming three public methods so one consumer can name them alike would be the tail wagging the dog.
type SnapshotSummary ¶
type SnapshotSummary struct {
SyncID string `json:"syncId"`
Scope []string `json:"scope"`
Counts map[string]int `json:"counts"`
Pages int `json:"pages"`
}
SnapshotSummary is what the caller is told, so the console can report the size of what it just queued rather than only that it queued something.
type Subscription ¶
type Subscription struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Events []string `json:"events"`
Status string `json:"status"`
CreatedAt time.Time `json:"createdAt"`
// HeaderNames, without their values. Serving the values back would make
// this endpoint a way to read every bearer token the tenant has stored,
// which is the thing sealing them was for.
HeaderNames []string `json:"headerNames,omitempty"`
}
Subscription is a subscription as the console sees it.
type SubscriptionInput ¶
type SubscriptionInput struct {
Name string
URL string
Events []string
// Headers are sent with every delivery. Values are credentials and are
// never served back — only the names are, which is enough to answer
// "what is this subscription sending".
Headers map[string]string
}
SubscriptionInput is what an administrator supplies.
type TenantFill ¶
type TenantFill struct {
TenantID string
Industry string
// Actor is whose name the audit trail carries for everything the fill
// does. The tenant's own administrator, so that clicking through from an
// entry lands on an account that exists.
Actor auth.Principal
// Password is what the demonstration accounts sign in with — one string for
// all of them, generated per tenant and sent to the visitor along with the
// administrator's own.
//
// Generated rather than published: these accounts share a password by
// design, so a fixed one would mean anybody who guessed a tenant code could
// sign in to somebody else's trial.
Password string
}
TenantFill is the request to fill a tenant that has just been created.
type TenantFiller ¶
type TenantFiller interface {
// Industries returns the pack keys on offer, in the order to show them.
Industries() []string
// Fill creates one pack's contents. It is called after the tenant and its
// administrator exist.
Fill(ctx context.Context, in TenantFill) error
}
TenantFiller creates demonstration data inside a tenant.
An interface here and an implementation elsewhere, because the packs are fixtures — a few hundred lines of invented people — and this package is the domain. It also breaks what would otherwise be an import cycle: the filler creates its contents by calling the services in this package.
type TenantService ¶
type TenantService struct {
// contains filtered or unexported fields
}
TenantService owns the tenant records themselves.
Unlike every other service it works through the unscoped store, because tenants are the root of the isolation hierarchy and have nothing above them to be scoped by. Provisioning is a command-line operation performed by whoever runs the deployment, which is what lets this have no cross-tenant administrator at all.
One method is reachable over HTTP, and only where a deployment asked: Overview, behind PORTICO_TENANT_CONSOLE. It returns counts and never contents — see internal/handler/tenant_console.go for what stands in front of it and why.
func NewTenantService ¶
func NewTenantService(st *store.Store) *TenantService
NewTenantService wires a TenantService.
func (*TenantService) Create ¶
func (s *TenantService) Create(ctx context.Context, code, name string, expiresAt *time.Time) (model.Tenant, error)
Create adds a tenant. Create makes a tenant.
expiresAt is nil for a tenant with no deadline, which is every tenant a person provisions by hand. It is a parameter rather than something the caller sets afterwards so that a tenant is never briefly immortal: a trial that created the tenant and then failed before writing the deadline would leave one nothing ever reclaims, and the quota it counts against never recovers.
func (*TenantService) EnsureDefault ¶
EnsureDefault creates the default tenant if the deployment has none, and returns it either way. It runs at every start, so an existing deployment is untouched.
func (*TenantService) Extend ¶
func (s *TenantService) Extend(ctx context.Context, code string, by time.Duration) (model.Tenant, error)
Extend moves a tenant's deadline out by a period, measured from now.
From now rather than from the old deadline: a tenant three days past its date and already disabled would otherwise be extended into the past and stay disabled, which reads as the button not working.
Refuses a tenant with no deadline. Giving one to a tenant that never had one would be taking something away, which is the opposite of what a caller asking to extend means.
Reads the row whatever its status, unlike Resolve — a disabled tenant is exactly the one somebody is extending.
func (*TenantService) Get ¶
Get returns a tenant by id. Used to attach the tenant's name to a session and to confirm a token's tenant still exists and is enabled.
func (*TenantService) OperatorConsole ¶
func (s *TenantService) OperatorConsole() bool
OperatorConsole reports whether the cross-tenant screens are enabled.
func (*TenantService) Overview ¶
func (s *TenantService) Overview(ctx context.Context) ([]model.TenantOverview, error)
Overview returns every tenant with a count of what is inside it.
The one read in this system that crosses the tenant boundary, and the narrowest crossing that answers the question: how many, never who. What stands in front of it is not this method — it is the route, which is not registered unless the deployment asked for an operator console and which then admits only an administrator of the default tenant.
func (*TenantService) Resolve ¶
Resolve looks up a tenant by code for sign-in, registration, and anything else that has to establish a tenant before it has a principal.
An empty code means the default tenant, so a single-tenant deployment never has to mention tenants.
func (*TenantService) SetExpiry ¶
func (s *TenantService) SetExpiry(ctx context.Context, code string, at *time.Time) (model.Tenant, error)
SetExpiry moves a tenant's deadline, or removes it with nil.
Returns the tenant as it now stands, so a caller does not have to read it back to show the new date.
func (*TenantService) SetStatus ¶
func (s *TenantService) SetStatus(ctx context.Context, code string, status model.Status) (model.Tenant, error)
SetStatus enables or disables a tenant. Disabling refuses sign-in but keeps every record, so it is reversible.
func (*TenantService) WithOperatorConsole ¶
func (s *TenantService) WithOperatorConsole(on bool) *TenantService
WithOperatorConsole records whether this deployment asked for the screens that see across tenants.
Held here rather than read from configuration at the point of use, for the same reason TrialService holds its own switch: the routes are the real gate, and this is what lets everything downstream of them — including the console, which has to decide whether to draw a menu entry — ask one place whether the feature exists.
type TrialRequestInput ¶
type TrialRequestInput struct {
Email string
CompanyName string
TenantCode string
Industry string
// Locale is the language to write to them in, as a tag the visitor's own
// interface was set to. Unrecognized or empty falls back to the
// deployment default — see trialLocale.
Locale string
}
TrialRequestInput is what the form collects.
type TrialService ¶
type TrialService struct {
// contains filtered or unexported fields
}
TrialService creates tenants for people who have no account anywhere.
func NewTrialService ¶
func NewTrialService( st *store.Store, tenants *TenantService, users *UserService, mailer notify.Mailer, audit *AuditService, enabled bool, maxTenants int, perHour int, publicURL string, ) *TrialService
NewTrialService wires a TrialService. A nil mailer is the same as no SMTP: requests are refused rather than accepted and dropped.
func (*TrialService) Confirm ¶
func (s *TrialService) Confirm(ctx context.Context, token, locale string) (TrialTenant, error)
Confirm spends a link, creates the tenant and its administrator, and mails the credentials.
No client address, unlike Request. There is nothing here to attribute it to: the audit trail is per tenant and this runs before one exists, and the address that mattered — the one that asked — is already on the row.
func (*TrialService) Enabled ¶
func (s *TrialService) Enabled() bool
Enabled reports whether this deployment offers trials, which is what the sign-in screen asks before drawing the entry point.
func (*TrialService) Industries ¶
func (s *TrialService) Industries() []string
Industries is what the sign-in screen offers, which is exactly what the filler can create.
func (*TrialService) Request ¶
func (s *TrialService) Request(ctx context.Context, in TrialRequestInput, ip string) error
Request records an intent and emails a link. It deliberately reports the same success whether or not the address was already used, except where the caller could have known — see the comment at the quota check.
func (*TrialService) SweepExpired ¶
func (s *TrialService) SweepExpired(ctx context.Context) (int64, error)
SweepExpired deletes unconfirmed requests whose links have expired, which is what returns a reserved tenant code to circulation.
func (*TrialService) SweepTenants ¶
func (s *TrialService) SweepTenants(ctx context.Context) (disabled, deleted int, err error)
SweepTenants closes and then clears out trial tenants whose time is up.
Two passes, in that order, because they are not the same act. The first disables a tenant whose fortnight has passed: sign-in stops, nothing is lost, and an operator who hears from the person can move the deadline and switch it back on. The second deletes one whose grace period has also passed, and that is the irreversible half — it is also the only half that gives anything back, since the quota, the tenant code and the hold on the applicant's mailbox are all released by removing the trial_requests row beside the tenant.
Without this the deployment closes itself. The quota counts confirmed requests, nothing ever decremented it, and so the fiftieth ordinary visitor was the last one — permanently, and with no error anywhere to say why.
The default tenant is skipped explicitly. It should never carry a deadline, and a bug that gave it one would otherwise take the whole deployment down on a timer.
func (*TrialService) WithBlockedEmailDomains ¶
func (s *TrialService) WithBlockedEmailDomains(extra []string) *TrialService
WithBlockedEmailDomains adds to the built-in list of throwaway mailbox providers.
Additive rather than a replacement: an operator adding the provider that their own visitors abuse should not have to restate the defaults, and one who pastes a short list into the environment would otherwise silently turn off everything else.
func (*TrialService) WithFillLimit ¶
func (s *TrialService) WithFillLimit(n int) *TrialService
WithFillLimit bounds how many tenants are seeded at once. Zero or less leaves it unbounded.
func (*TrialService) WithFiller ¶
func (s *TrialService) WithFiller(f TenantFiller) *TrialService
WithFiller attaches the demonstration packs.
Separate from the constructor, which already takes eight arguments, and separate for a second reason: this is the one dependency that reaches back into a package that depends on this one, so keeping it visible at the assembly site is worth a line.
func (*TrialService) WithLocale ¶
func (s *TrialService) WithLocale(locale string) *TrialService
WithLocale sets the language the trial messages are written in.
The deployment default, for the reason given on the field: there is nobody to ask. Unset leaves English.
func (*TrialService) WithMetrics ¶
func (s *TrialService) WithMetrics(reg *metrics.Registry) *TrialService
WithMetrics publishes the trial quota to an operator.
Optional, and separate from the constructor because most callers of this service are not the server: the seeding command and the tests build one too.
type TrialTenant ¶
type TrialTenant struct {
TenantCode string
TenantName string
AdminUsername string
AdminPassword string
SignInURL string
// DemoPassword is what the seeded accounts sign in with, and is empty when
// nothing was seeded.
//
// Given out because looking at the portal as an ordinary person is half of
// what there is to see, and the administrator's own account cannot show it.
// Every one of these accounts is an ordinary user, so handing out one
// password for all of them costs nothing an administrator has.
DemoPassword string
// Industry is the pack that was created, or empty if the fill failed. Said
// out loud rather than assumed from the request: a visitor who asked for a
// hospital and got an empty tenant should be able to tell.
Industry string
}
TrialTenant is what a confirmed link produced. The password is here once, on its way into an email, and is not stored anywhere in readable form.
type UpdateCASInput ¶
type UpdateCASInput struct {
Name string
// URLPrefix may be changed: it is a deployment address rather than an
// identity, and an application that moves host has to be followable
// without de-registering it.
URLPrefix string
LaunchURL string
LogoURI string
}
UpdateCASInput is the editable part of a CAS registration.
type UpdateClientInput ¶
type UpdateClientInput struct {
Name string
ApplicationType string
RedirectURIs []string
PostLogoutRedirectURIs []string
Scopes []string
LaunchURL string
LogoURI string
}
UpdateClientInput is the editable part of a registration.
The client id is absent because it is not editable: it is the name the application presents at the token endpoint, and changing it would break every deployment of that application rather than reconfigure it. Whether the client is confidential is absent for the same reason — flipping a public client to confidential would leave it unable to authenticate until somebody noticed, so that is a re-registration.
type UpdateSPInput ¶
UpdateSPInput is the editable part of a service provider registration.
Replacing the metadata document is how a service provider's signing or encryption certificate is rotated, so it has to be editable for a registration to survive past its first certificate expiry.
type UpdateUserInput ¶
type UpdateUserInput struct {
DisplayName string
Phone string
Email string
Role model.Role
OrganizationID string
}
UpdateUserInput changes an account's profile. Password and status have their own operations because they carry different authorization rules.
type UserAttributeInput ¶
type UserAttributeInput struct {
// Key is accepted on creation and ignored on update: it is what a mapping
// stores, so renaming it would silently stop a mapping that names it, in a
// system Portico does not own and cannot warn.
Key string
Label string
Description string
Kind string
// AllowedValues applies to SELECT and is ignored otherwise.
AllowedValues []string
Required bool
SortOrder int
}
UserAttributeInput is a definition as an administrator describes it.
type UserAttributeService ¶
type UserAttributeService struct {
// contains filtered or unexported fields
}
UserAttributeService manages the attributes a tenant defines for itself, and the values recorded against accounts.
The twenty-five specification-derived attributes are columns and are edited through the profile endpoint. These are the other kind: a fact a tenant has about its people that SCIM's schema has no name for — a badge number, a contract end date, a site code. Without them the answer is to overload `costCenter` and hope nobody notices.
func NewUserAttributeService ¶
func NewUserAttributeService(st *store.Store, audit *AuditService) *UserAttributeService
NewUserAttributeService wires the service.
func (*UserAttributeService) Define ¶
func (s *UserAttributeService) Define(ctx context.Context, actor auth.Principal, in UserAttributeInput) (model.UserAttributeDefinition, error)
Define adds an attribute.
func (*UserAttributeService) Definitions ¶
func (s *UserAttributeService) Definitions(ctx context.Context, tenantID string) ([]model.UserAttributeDefinition, error)
Definitions returns the tenant's attribute definitions.
func (*UserAttributeService) Delete ¶
Delete removes an attribute and every value recorded against it.
Audited with the count, because that count is the whole of what was lost and it is not recoverable. Disabling is the ordinary path; this is the other one.
func (*UserAttributeService) SetStatus ¶
func (s *UserAttributeService) SetStatus(ctx context.Context, actor auth.Principal, id string, status model.Status) (model.UserAttributeDefinition, error)
SetStatus retires an attribute or brings it back.
Retiring keeps every value already recorded. It is the ordinary way to stop using one, and the reason it exists beside Delete is that the values are often the answer to a question somebody asks later.
func (*UserAttributeService) SetValues ¶
func (s *UserAttributeService) SetValues(ctx context.Context, actor auth.Principal, userID string, values map[string]string) error
SetValues records values for an account, by attribute key.
A key absent from the map is left alone and an empty value clears it, which is the same contract the profile endpoint has. Clearing removes the row rather than storing an empty string, so that "never filled in" and "deliberately blank" stay distinguishable — the outbound rule is that nothing is sent empty, and a stored empty string would be a value that is configured and silently never arrives.
func (*UserAttributeService) Update ¶
func (s *UserAttributeService) Update(ctx context.Context, actor auth.Principal, id string, in UserAttributeInput) (model.UserAttributeDefinition, error)
Update changes an attribute's editable parts. The key is not among them.
func (*UserAttributeService) Values ¶
func (s *UserAttributeService) Values(ctx context.Context, tenantID, userID string) (map[string]string, error)
Values returns one account's custom values, keyed by attribute key.
Disabled attributes are left out: a value that is neither shown nor sent is not part of the account as anybody sees it.
type UserQuery ¶
type UserQuery struct {
// Keyword matches the username or display name (§3.1).
Keyword string
// Status and Role are exact filters; empty means all.
Status model.Status
Role model.Role
// OrganizationID selects an organization and everything under it, not
// that one organization alone. Empty means all of them, and
// UnassignedOrganization means the people in none.
//
// The subtree is the whole point: this filter is reached from a tree, and
// picking a division and being shown only the handful of people filed
// directly against the division itself — rather than everybody in it —
// reads as a defect rather than as a distinction. The narrower question
// has never been asked; the broader one is asked constantly.
OrganizationID string
}
UserQuery filters a user listing.
type UserService ¶
type UserService struct {
// contains filtered or unexported fields
}
UserService owns account lifecycle and credentials.
Every method that touches accounts is scoped to a tenant. Methods acting on behalf of a signed-in caller take the tenant from their principal; those that run before there is one — sign-in, registration, bootstrap — take it explicitly from a tenant that has already been resolved and found active.
func NewUserService ¶
func NewUserService(st *store.Store, audit *AuditService, settings *SettingsService, tokens *auth.TokenService, m *metrics.Registry) *UserService
NewUserService wires a UserService.
func (*UserService) BulkSetOrganization ¶
func (s *UserService) BulkSetOrganization(ctx context.Context, actor auth.Principal, userIDs []string, organizationID string) (BulkResult, error)
BulkSetOrganization moves several accounts into one organization, or out of any with an empty id.
Through Update, for the same reason as above: it is the path that validates the organization exists, belongs to this tenant, and is not disabled.
func (*UserService) BulkSetStatus ¶
func (s *UserService) BulkSetStatus(ctx context.Context, actor auth.Principal, userIDs []string, status model.Status) (BulkResult, error)
BulkSetStatus enables or disables several accounts.
Each one goes through SetStatus rather than a single UPDATE, and that is the point rather than an oversight: every rule that applies to disabling one account — the last administrator cannot be disabled, nobody can disable themselves, sessions and federated tokens end immediately, the trail records it — applies to each of these. A bulk path that wrote straight to the table would be a way around all of them, and the way around would be invisible.
Failures are collected rather than fatal. An operator who selected forty people and hit one they may not disable wants the other thirty-nine done and a note about the one, not a refusal with nothing changed.
func (*UserService) ChangeExpiredPassword ¶
func (s *UserService) ChangeExpiredPassword(ctx context.Context, tenant model.Tenant, identifier, currentPassword, newPassword, ip, userAgent string) (Session, error)
ChangeExpiredPassword is the way back in for somebody whose password has aged out — or who is still on the default one a release bootstraps with, which sign-in refuses on the same terms.
It takes credentials rather than a session because there is no session to take: Login refuses an expired password outright rather than issuing a token and trusting the client to act on a flag. So this re-checks the old password itself, applies the same lockout accounting a sign-in would, and issues the session once the new password is set.
It refuses when the password is neither expired nor one that must be replaced, so it cannot be used as an alternative change-password endpoint that skips being signed in.
func (*UserService) ChangeOwnPassword ¶
func (s *UserService) ChangeOwnPassword(ctx context.Context, actor auth.Principal, currentPassword, newPassword, ip string) error
ChangeOwnPassword lets a signed-in user replace their password after proving they know the current one.
func (*UserService) ClearRecoveryLimit ¶
func (s *UserService) ClearRecoveryLimit(ctx context.Context, actor auth.Principal, userID string) (model.User, error)
ClearRecoveryLimit gives an account its daily password-recovery allowance back, without changing the password and without touching the lockout.
It is the third of a set, and the distinction between them is the whole reason there are three. Unlock answers "they mistyped their password". ResetPassword answers "they have lost it". This answers "they asked for a reset link too many times and we quietly stopped sending" — which, before this existed, only ResetPassword could answer, and answering it that way means an administrator reading a password down a telephone to somebody who had not actually lost theirs.
No confirmation is asked for in the console, on the same grounds as extending a tenant: it takes nothing away. The worst outcome of clearing an allowance nobody had spent is that nothing happens.
func (*UserService) CloseOwnAccount ¶
func (s *UserService) CloseOwnAccount(ctx context.Context, actor auth.Principal, password, ip string) error
CloseOwnAccount is the one sanctioned way to disable yourself.
Everywhere else that is refused — ErrCannotDisableSelf exists so an administrator cannot lock themselves out by accident. This is not an exception to that rule so much as the case it was never about: somebody deliberately leaving, having been asked to confirm.
It deactivates rather than deletes, matching every other decision here, so the audit trail keeps pointing at an account that exists and an administrator can undo a mistake. Whether a deployment needs the irreversible kind is a question about personal-data erasure obligations rather than about this code.
func (*UserService) Create ¶
func (s *UserService) Create(ctx context.Context, tenantID string, in CreateUserInput) (model.User, error)
Create adds an account to a tenant. The caller is responsible for having checked that the actor is an administrator of that tenant.
func (*UserService) EnsureInitialAdmin ¶
func (s *UserService) EnsureInitialAdmin(ctx context.Context, tenantID, username, password string) (created bool, mustChange bool, err error)
EnsureInitialAdmin creates the bootstrap administrator when a tenant has no users at all.
The check is per tenant, not per deployment: every tenant needs its own first administrator, since no account can administer more than one. That is also what lets the provisioning CLI reuse this when creating a tenant.
A caller that supplied no password gets DefaultInitialAdminPassword and an account that must replace it at first sign-in; mustChange reports that, so the caller can say so where it announces the account. A caller that chose one is left alone: they picked a secret that is not in any manual, and forcing a change would break every unattended install that signs in with the password it just configured.
The test is the value, not how it arrived. Somebody who sets PORTICO_INITIAL_ADMIN_PASSWORD to the published default has configured the same publicly known credential as somebody who set nothing, and it is the value being public that the forced change answers.
func (*UserService) ExportUsers ¶
func (s *UserService) ExportUsers(ctx context.Context, actor auth.Principal, q UserQuery) (*excelize.File, int, error)
ExportUsers writes a tenant's accounts as a spreadsheet.
The same column order the import template uses, so a file exported here can be edited and fed back in — which is what "bulk operations" means in practice for most of the people who ask for it.
No password is exported. The column is there and empty, which is not an oversight to tidy up: the parser reads columns by position, so that a translated header still works, and an export missing this one would shift every field after it one place to the left on the way back in — silently. The heading stays; the values do not. An export is a report, and a report that carries credentials is a credential-distribution mechanism nobody meant to build.
func (*UserService) FindByExternalID ¶
func (s *UserService) FindByExternalID(ctx context.Context, tenantID, externalID string) (model.User, error)
FindByExternalID resolves the identifier a directory knows an account by.
func (*UserService) ImportUsers ¶
func (s *UserService) ImportUsers(ctx context.Context, actor auth.Principal, r io.Reader, ip string) (ImportResult, error)
ImportUsers creates accounts from an uploaded spreadsheet.
Rows are independent: a bad row is reported and skipped rather than aborting the batch. A partial import is far more useful than an all-or-nothing failure on a thousand-row migration file, and the caller gets a per-row report of what to fix and re-upload.
func (*UserService) IssueSessionForExternalIdentity ¶
func (s *UserService) IssueSessionForExternalIdentity(ctx context.Context, tenant model.Tenant, userID, ip, userAgent string) (session Session, err error)
IssueSessionForExternalIdentity signs somebody in on another provider's word, once that word has been checked.
Everything a password sign-in checks *after* the password is checked here too, because none of it is about the password: a disabled account is disabled whoever vouched for it, an account its owner closed is asking to come back rather than to be let in, and a locked one is locked.
Two things a password sign-in does are deliberately absent. Expiry and the forced change of a default password are conditions on a credential that is not being used — an account signing in through Google is not presenting the password, and refusing it would be asking somebody to fix something they are not holding. Neither is reachable by accident: binding an identity requires an ordinary sign-in first, so an account that cannot pass the password gate cannot arrive here to skip it.
The lockout counter is not touched either, in either direction. It counts password guesses, and a successful external sign-in is not evidence that whoever was guessing has stopped.
func (*UserService) List ¶
func (s *UserService) List(ctx context.Context, tenantID string, q UserQuery, page Page) ([]model.User, int64, error)
List returns a page of users, newest first.
Hand-written because the filters are optional and sqlc cannot express a query whose WHERE clause varies. The tenant predicate is written into the SQL rather than added by the filter builder, so it is visible in the query and checked by the guard test in internal/store.
func (*UserService) Login ¶
func (s *UserService) Login(ctx context.Context, tenant model.Tenant, identifier, password, ip, userAgent string) (session Session, err error)
Login verifies credentials within a tenant and issues a token.
The identifier may be a username, an email address, or a phone number (§3.4). All three produce exactly the same session — there is one credential check, one token, one audit entry, and nothing downstream can tell which was used. That is the requirement: the identifier is a way of naming an account, not a kind of account.
The tenant is resolved by the caller before this runs, and is not something the credentials can influence: all three identifiers are unique per tenant, so "which tenant" has to be settled first or the lookup is ambiguous.
Every failure returns the same ErrInvalidCredentials regardless of whether the account exists, so the response cannot be used to enumerate accounts. Two answers are more specific — disabled, and locked — and both are reached only after the password has matched, so they are available to somebody who could already establish the account exists. Being vague at that point costs a person who typed the right password the one piece of information that would tell them what to do.
func (*UserService) Logout ¶
Logout ends the session the caller is using.
This one, not all of them. Before sessions existed the only revocation available was bumping token_version, which invalidates every token an account holds — so signing out on a laptop signed you out on your phone as well. That was never intended, only unavoidable. LogoutEverywhere is the deliberate version.
Federated sessions are a separate question, and they still all go. Signing out of Portico could reasonably leave a relying party's own session running, since that is what its end_session endpoint is for. It does not, because "sign out" on a single sign-on system is read by the person clicking it as signing out of the things they signed in to, and the surprising failure is the one where it did less than they thought. A second browser stays signed in to Portico; nothing stays signed in to the applications.
func (*UserService) LogoutEverywhere ¶
LogoutEverywhere ends every session the account holds, on every device.
What somebody reaches for when they think a session is not theirs. It bumps token_version as well as revoking the rows: that is redundant while both mechanisms agree, and it is the cheap insurance that a token which somehow escaped the session check still stops working.
func (*UserService) LookupForAuth ¶
LookupForAuth implements auth.UserLookup. It runs on every authenticated request, so it stays a single indexed read.
This is the one account read that is not tenant-scoped, because it is what establishes the tenant: all it has to go on is the subject of a token. The middleware compares the tenant it returns against the token's claim, so the absence of a filter here does not widen what a token can reach. See internal/store/queries/authentication.sql.
func (*UserService) PasswordExpiryFor ¶
func (s *UserService) PasswordExpiryFor(ctx context.Context, tenantID, userID string) (*time.Time, error)
PasswordExpiryFor is when a person's password stops working, or nil when it does not.
This exists so the home screen can warn somebody a few days out instead of letting them discover it at the sign-in screen of a morning they were busy. It returns the instant rather than the policy, deliberately: the policy is administrator-only, and a normal user does not need to be told the tenant's rules to be told their own deadline.
A password that has never been changed under a policy that expires them is already due, which Expired treats as such; the instant returned then is in the past, and the screen says "expired" rather than counting down.
func (*UserService) PasswordPolicyFor ¶
func (s *UserService) PasswordPolicyFor(ctx context.Context, tenantID string) (PasswordPolicy, error)
PasswordPolicyFor reads a tenant's policy.
func (*UserService) ProvisionUser ¶
func (s *UserService) ProvisionUser(ctx context.Context, tenantID string, in ProvisionUserInput) (model.User, error)
ProvisionUser creates an account on behalf of a directory.
Reconciliation is part of the contract rather than an optimisation: a provisioning client that loses track of an account POSTs it again, and a server that answered "created" with a second row would duplicate the directory. When the externalId already exists the existing account is updated instead, which is what the client's own next GET would show anyway.
func (*UserService) Register ¶
func (s *UserService) Register(ctx context.Context, tenantID string, in RegisterInput, ip string) (model.User, error)
Register creates an account from a public sign-up request, in the tenant the caller named (or the default one).
The role is always USER and is never taken from the request: letting a caller pick their own role would make the whole permission model meaningless. Organization is left empty for an administrator to fill in later (§3.4.2).
func (*UserService) ResetPassword ¶
func (s *UserService) ResetPassword(ctx context.Context, actor auth.Principal, userID, newPassword, ip string) error
ResetPassword lets an administrator set another account's password without knowing the old one.
func (*UserService) SetProfile ¶
func (s *UserService) SetProfile(ctx context.Context, actor auth.Principal, userID string, in model.UserProfile) (model.User, error)
SetProfile writes the descriptive attributes of an account.
Separate from Update, which changes role, status, and organization. Those are decisions about somebody's access; these describe them. A single endpoint taking both would mean a form that edits a job title has to send a role, and sending the wrong one by omission is how a self-service screen becomes a privilege-escalation endpoint.
func (*UserService) SetProvisionedUserActive ¶
func (s *UserService) SetProvisionedUserActive(ctx context.Context, tenantID, userID string, active bool) (model.User, error)
SetProvisionedUserActive is deprovisioning on its own, for DELETE.
It goes through UpdateProvisionedUser rather than writing status directly, so that DELETE and PATCH active=false cannot drift apart — one of them revoking sessions and the other not is a difference nobody would notice until somebody left.
func (*UserService) SetStatus ¶
func (s *UserService) SetStatus(ctx context.Context, actor auth.Principal, userID string, status model.Status) (model.User, error)
SetStatus enables or disables an account. Disabling also revokes any live session, which the query handles by bumping token_version.
func (*UserService) Unlock ¶
func (s *UserService) Unlock(ctx context.Context, actor auth.Principal, userID string) (model.User, error)
Unlock clears a lockout without changing the password.
Separate from resetting the password because the two answer different situations. Somebody who mistyped five times and cannot wait fifteen minutes needs the lock gone and their password left alone; forcing a reset on them would be an administrator handing out a password over the phone, which is worse than the lockout.
func (*UserService) Update ¶
func (s *UserService) Update(ctx context.Context, actor auth.Principal, userID string, in UpdateUserInput) (model.User, error)
Update changes a user's profile, role, and organization.
func (*UserService) UpdateOwnProfile ¶
func (s *UserService) UpdateOwnProfile(ctx context.Context, actor auth.Principal, in ProfileInput, ip string) (model.User, error)
UpdateOwnProfile lets a signed-in user maintain their own details (§3.5).
Phone and email are sign-in identifiers and password-recovery destinations, so they go through the same validation and the same per-tenant uniqueness as when an administrator sets them.
Changing either is not verified: a user may set an address they do not control. That is bounded — recovery for their own account would then be delivered somewhere they cannot read, which locks them out rather than letting them in, and the unique index stops them taking an address another account in the tenant already holds. Verified changes are a V0.2 item.
func (*UserService) UpdateProvisionedUser ¶
func (s *UserService) UpdateProvisionedUser(ctx context.Context, tenantID, userID string, in ProvisionUserInput) (model.User, error)
UpdateProvisionedUser applies a directory's view of an account.
func (*UserService) WithEvents ¶
func (s *UserService) WithEvents(publisher EventPublisher) *UserService
WithEvents attaches a publisher. Separate from the constructor because the webhook service is built after this one and only needs to be known by the operations that emit.
type ValidatedTicket ¶
ValidatedTicket is who a spent ticket was about.
type VerificationService ¶
type VerificationService struct {
// contains filtered or unexported fields
}
VerificationService issues and redeems address-verification tokens.
func NewVerificationService ¶
func NewVerificationService( st *store.Store, users *UserService, settings *SettingsService, audit *AuditService, mailer notify.Mailer, sms notify.SMSSender, publicURL string, ) *VerificationService
NewVerificationService wires a VerificationService.
func (*VerificationService) Confirm ¶
func (s *VerificationService) Confirm(ctx context.Context, tenantID, token, ip string) error
Confirm redeems a token and marks the address proven.
func (*VerificationService) Required ¶
Required reports whether this tenant makes a self-registered account prove its address before it can sign in.
func (*VerificationService) Resend ¶
func (s *VerificationService) Resend(ctx context.Context, tenant model.Tenant, destination, ip string)
Resend issues another token for whoever holds destination.
It returns nil whether or not an account was found, whether or not that account still needs verifying, and whether or not delivery worked. The endpoint is public and unauthenticated, so reporting the difference would make it an oracle for "does this address have an account here".
That is a deliberate asymmetry with sign-in, which *does* disclose: a person refused for being unverified has to be told why, or they have no way forward at all. The disclosure is confined to somebody who already has the password.
type WebhookService ¶
type WebhookService struct {
// contains filtered or unexported fields
}
WebhookService owns subscriptions and the queue of things to send them.
Publishing is a database write and nothing more: the event is queued and the request that caused it returns. An outbound HTTP call on the request path would make creating a user as slow as the slowest subscriber and would fail the creation when a subscriber is down — which is the wrong way round, because the account was created either way.
func NewWebhookService ¶
func NewWebhookService(st *store.Store, audit *AuditService) *WebhookService
NewWebhookService wires a WebhookService.
func (*WebhookService) Create ¶
func (s *WebhookService) Create(ctx context.Context, actor auth.Principal, in SubscriptionInput) (CreatedSubscription, error)
Create registers a subscription and returns its signing secret.
func (*WebhookService) Deliveries ¶
func (s *WebhookService) Deliveries( ctx context.Context, tenantID, subscriptionID, cursor, filter string, pageSize int32, ) (DeliveryPage, error)
Deliveries returns one page of a subscription's attempts, newest first.
cursor is the NextCursor of the previous page, or "" for the first.
func (*WebhookService) Delivery ¶
func (s *WebhookService) Delivery(ctx context.Context, tenantID, subscriptionID, deliveryID string) (DeliveryDetail, error)
Delivery returns one attempt in full.
Request headers are deliberately not included, here or in the row: a subscription's custom headers are credentials, sealed precisely so a database dump does not yield them, and a debugging screen is not a reason to copy them into one.
func (*WebhookService) DispatchDue ¶
func (s *WebhookService) DispatchDue(ctx context.Context, tenantID string, client *http.Client) (int, error)
DispatchDue delivers whatever is due for one tenant, and reports how many it attempted.
Called on a timer by the server; see cmd/server. It is a pass rather than a long-running loop so that it shares the sweep's schedule and its failure mode — a pass that errors is retried on the next tick, and nothing has to supervise a goroutine.
func (*WebhookService) List ¶
func (s *WebhookService) List(ctx context.Context, tenantID string) ([]Subscription, error)
List returns a tenant's subscriptions, without secrets.
func (*WebhookService) Publish ¶
func (s *WebhookService) Publish(ctx context.Context, tenantID, eventType string, data any)
Publish queues an event for every subscription that selected it.
Errors are logged and swallowed, deliberately. This is called from inside operations that have already succeeded — the account exists, the organization was renamed — and failing them because a notification could not be queued would undo work that was correct, to report a problem with telling somebody about it.
func (*WebhookService) RotateSecret ¶
func (s *WebhookService) RotateSecret(ctx context.Context, actor auth.Principal, id string) (CreatedSubscription, error)
RotateSecret issues a new signing key and keeps the old one alive briefly.
The overlap is the whole point. Portico produces the signature and the receiver verifies it, so the receiver is the side that has to deploy something, and a rotation that took effect instantly would reject every delivery until they had. During the overlap each delivery carries both signatures, comma-separated, and the receiver accepts either.
That has a consequence worth stating plainly rather than discovering: a receiver comparing the whole X-Portico-Signature header as one string verifies nothing from the moment this is called until the overlap ends. Splitting on "," is a requirement of the protocol, not a nicety, and the console says so before starting one.
The subscription id does not change. Deleting and re-registering was the only previous remedy for a leaked key, and it discarded the delivery history and broke deduplication at the far end — the cure destroyed the evidence.
func (*WebhookService) SetStatus ¶
func (s *WebhookService) SetStatus(ctx context.Context, actor auth.Principal, id string, status model.Status) error
SetStatus pauses or resumes a subscription.
func (*WebhookService) SnapshotPreview ¶
func (s *WebhookService) SnapshotPreview(ctx context.Context, tenantID, subscriptionID string) (SnapshotSummary, error)
SnapshotPreview answers what a full sync would send, without sending it.
The console asks before it puts the question to an operator, because "queue a copy of everything?" is a different decision at fifty accounts and at fifty thousand — and the only place that number was available was the screen shown after it had already been queued.
Counted rather than built. The real run pages every object out of the database and renders it through the subscription's field mappings, which is far too much work to do twice for a sentence in a dialog; three counts and a division give the same numbers. They can be stale by the time the operator clicks, which does not matter: this is an order of magnitude, not an invoice.
func (*WebhookService) StartSnapshot ¶
func (s *WebhookService) StartSnapshot(ctx context.Context, actor auth.Principal, subscriptionID string) (SnapshotSummary, error)
StartSnapshot queues a full copy of what exists for one subscription.
Everything is queued in one pass rather than streamed as the dispatcher drains it. The alternative — a producer that keeps state between passes — would need somewhere to keep it and a story for a process that restarts mid-snapshot. Queued rows already have both: they are in the table, and the dispatcher retries them.
func (*WebhookService) SweepDeliveries ¶
SweepDeliveries removes finished deliveries past their retention.
func (*WebhookService) WithFieldMappings ¶
func (s *WebhookService) WithFieldMappings(catalogue *FieldCatalogue, mappings *FieldMappingService) *WebhookService
WithFieldMappings attaches what applying a subscription's rules needs.
Separate from the constructor, on the same terms as WithVault: a service built without it delivers exactly what it always did, which is what every test that only cares about delivery wants — and what a caller that forgot to wire it should get, rather than a panic in the path that notifies other systems that an account was disabled.
func (*WebhookService) WithSnapshotSource ¶
func (s *WebhookService) WithSnapshotSource(src SnapshotSource) *WebhookService
WithSnapshotSource attaches the readers a snapshot needs. Without it, StartSnapshot refuses rather than sending an empty one — a receiver that was told a snapshot ran and got nothing would conclude the tenant is empty.
func (*WebhookService) WithVault ¶
func (s *WebhookService) WithVault(v *secrets.Vault) *WebhookService
WithVault attaches the key custom headers are sealed under. Separate from the constructor because the vault is built later in server.New, on the same terms as WithEvents elsewhere.
Source Files
¶
- application_logo.go
- audit.go
- auth_flow.go
- cas.go
- common.go
- directory.go
- external_idp.go
- external_signin.go
- field_cas.go
- field_catalogue.go
- field_mapping.go
- field_oidc.go
- field_saml.go
- field_values.go
- field_webhook.go
- group.go
- import.go
- mail.go
- oauth_client.go
- organization.go
- password_policy.go
- recovery.go
- saml_key.go
- saml_provider.go
- scim_credential.go
- scim_provision.go
- self_service.go
- session.go
- settings.go
- signing_key.go
- tenant.go
- trial.go
- trial_domains.go
- trial_mail.go
- user.go
- user_attribute.go
- verification.go
- webhook.go
- webhook_sync.go
- webhook_worker.go