service

package
v0.0.0-...-b433d53 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 26, 2026 License: Apache-2.0 Imports: 52 Imported by: 0

Documentation

Overview

Package service holds the business logic that sits between the HTTP handlers and the store.

Index

Constants

View Source
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.

View Source
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.

View Source
const (
	FieldGroupIdentity     = "identity"
	FieldGroupProfile      = "profile"
	FieldGroupOrganization = "organization"
	FieldGroupTenant       = "tenant"
	FieldGroupCustom       = "custom"
)

Field groups, which is how the console sorts the picker. Not semantic.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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
)
View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
var (
	ErrRecoveryUnavailable = httpx.NewError(503, "RECOVERY_UNAVAILABLE",
		"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.

View Source
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.

View Source
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.")
	// ErrSCIMUnauthorized is what every authentication failure becomes,
	// 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.

View Source
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.

View Source
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.")
)
View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
var ErrSessionNotFound = httpx.NotFound("SESSION_NOT_FOUND", "No such session.")

ErrSessionNotFound is returned when no live session has that id.

View Source
var ErrSnapshotUnavailable = httpx.UnprocessableEntity("SNAPSHOT_UNAVAILABLE",
	"This deployment cannot produce a snapshot.")

ErrSnapshotUnavailable is returned when nothing was attached to read from.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
var ErrVerificationUnavailable = httpx.NewError(503, "VERIFICATION_UNAVAILABLE",
	"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.

View Source
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.

View Source
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

func ApplicationLogoPath(tenantCode, logoID string) string

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

func CASAttributeFor(out Outbound, key string) (name string, send bool)

CASAttributeFor decides one default attribute's fate under a recipient's rules.

func CASAttributeNames

func CASAttributeNames() map[string]string

CASAttributeNames is the default set, for the guard tests that hold this table and the documentation in step.

func ClaimFor

func ClaimFor(out Outbound, key string) (name string, send, renamed bool)

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

func CommandLineActor(tenantID string) auth.Principal

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

func ImportTemplate() (*excelize.File, error)

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

func IsBuiltInFieldKey(key string) bool

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

func MatchCASService(prefix, service string) bool

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

func OIDCClaimNames() map[string]string

OIDCClaimNames is the default claim set, for the guard tests that hold the documentation and this table in step.

func OIDCDefaultClaim

func OIDCDefaultClaim(key string) (string, bool)

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

func ValidDeliveryFilter(f string) bool

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) Get

func (s *ApplicationLogoService) Get(ctx context.Context, tenantID, id string) (Logo, error)

Get returns a stored logo for serving.

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

type CASAddition struct {
	Name  string
	Value string
}

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

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

type DeliveryPage struct {
	Items      []Delivery `json:"items"`
	NextCursor string     `json:"nextCursor"`
}

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

type DirectoryReader interface {
	Users() ([]directory.Entry, []error, error)
	Close()
}

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

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

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

func (s *ExternalIDPService) BoundCount(ctx context.Context, tenantID, id string) (int64, error)

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) Delete

func (s *ExternalIDPService) Delete(ctx context.Context, actor auth.Principal, id string) error

Delete removes a provider and the bindings that named it.

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) Field

func (c *FieldCatalogue) Field(ctx context.Context, tenantID, key string) (Field, error)

Field looks one up by key, across both halves.

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

func (c *FieldCatalogue) Fields(ctx context.Context, tenantID string) ([]Field, error)

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

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

type GroupInput struct {
	DisplayName string
	Description string
	ExternalID  string
}

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) Delete

func (s *GroupService) Delete(ctx context.Context, tenantID, id string, actor auth.Principal) error

Delete removes a group and its memberships.

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) Get

func (s *GroupService) Get(ctx context.Context, tenantID, id string) (model.Group, error)

Get returns one group with its member count.

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) List

func (s *GroupService) List(ctx context.Context, tenantID string) ([]model.Group, error)

List returns the tenant's groups with member counts.

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 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

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

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

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

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.

func (Outbound) Empty

func (o Outbound) Empty() bool

Empty reports whether anything was configured, so a caller can take the default path without a lookup per field.

func (Outbound) NameFor

func (o Outbound) NameFor(sourceKey, defaultName string) (name string, send bool)

NameFor answers what a default should be called, and whether to send it at all. It returns the name unchanged when no rule names this field.

type Page

type Page struct {
	Limit  int
	Offset int
}

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

func (p PasswordPolicy) Expired(changedAt *time.Time, now time.Time) bool

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

type ProfileInput struct {
	DisplayName string
	Phone       string
	Email       string
}

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

type SAMLAttribute struct {
	Name         string
	FriendlyName string
}

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

func (s *SAMLKeyService) Active(ctx context.Context, tenantID string) (SAMLKey, error)

Active returns the key assertions are signed with, generating one on first use.

func (*SAMLKeyService) List

func (s *SAMLKeyService) List(ctx context.Context, tenantID string) ([]SAMLKey, error)

List returns every key a tenant has had, active first.

func (*SAMLKeyService) Rotate

func (s *SAMLKeyService) Rotate(ctx context.Context, tenantID string) (SAMLKey, error)

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

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

List returns every service provider in a tenant.

func (*SAMLServiceProviderService) Register

Register adds a service provider to the actor's tenant.

func (*SAMLServiceProviderService) SetStatus

SetStatus enables or disables a service provider.

func (*SAMLServiceProviderService) Update

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

Create issues a credential and returns the only copy of its token.

func (*SCIMCredentialService) Delete

func (s *SCIMCredentialService) Delete(ctx context.Context, actor auth.Principal, id string) error

Delete revokes a credential permanently.

func (*SCIMCredentialService) List

func (s *SCIMCredentialService) List(ctx context.Context, tenantID string) ([]SCIMCredential, error)

List returns a tenant's credentials, without tokens.

func (*SCIMCredentialService) SetStatus

func (s *SCIMCredentialService) SetStatus(ctx context.Context, actor auth.Principal, id string, status model.Status) error

SetStatus enables or disables a credential.

type SCIMPrincipal

type SCIMPrincipal struct {
	CredentialID string
	TenantID     string
	Name         string
}

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

func (s Settings) LockoutDuration() time.Duration

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

func (s Settings) LockoutEnabled() bool

LockoutEnabled reports whether this tenant locks accounts at all.

func (Settings) OIDCAccessTokenLifetime

func (s Settings) OIDCAccessTokenLifetime() time.Duration

OIDCAccessTokenLifetime is how long an access token this server issues stays valid. The ID token gets the same, deliberately; see SettingOIDCAccessTokenTTLMinutes.

func (Settings) OIDCRefreshTokenLifetime

func (s Settings) OIDCRefreshTokenLifetime() time.Duration

OIDCRefreshTokenLifetime is how long a refresh token stays usable before it has to be exchanged. Rotation resets it.

func (Settings) OIDCSessionCapped

func (s Settings) OIDCSessionCapped() bool

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

func (s Settings) OIDCSessionMaxAge() time.Duration

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.

func (Settings) TokenTTL

func (s Settings) TokenTTL() time.Duration

TokenTTL is the console session's lifetime as a duration.

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

func (s *SettingsService) Get(ctx context.Context, tenantID string) (Settings, error)

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

func (s *SettingsService) RegistrationEnabled(ctx context.Context, tenantID string) (bool, error)

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

type SignInOption struct {
	ID    string `json:"id"`
	Label string `json:"label"`
}

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

func (s *TenantService) EnsureDefault(ctx context.Context) (model.Tenant, error)

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

func (s *TenantService) Get(ctx context.Context, id string) (model.Tenant, error)

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) List

func (s *TenantService) List(ctx context.Context) ([]model.Tenant, error)

List returns every tenant, for the provisioning CLI.

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

func (s *TenantService) Resolve(ctx context.Context, code string) (model.Tenant, error)

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

type UpdateSPInput struct {
	MetadataXML string
	Name        string
	LaunchURL   string
	LogoURI     string
}

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

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

func (s *UserAttributeService) Delete(ctx context.Context, actor auth.Principal, id string) error

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

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

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) Get

func (s *UserService) Get(ctx context.Context, tenantID, userID string) (model.User, error)

Get returns one user with their organization name resolved.

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

func (s *UserService) Logout(ctx context.Context, actor auth.Principal, ip string) error

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

func (s *UserService) LogoutEverywhere(ctx context.Context, actor auth.Principal, ip string) error

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

func (s *UserService) LookupForAuth(ctx context.Context, userID string) (auth.Account, error)

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

type ValidatedTicket struct {
	User model.User
}

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

func (s *VerificationService) Required(ctx context.Context, tenantID string) (bool, error)

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.

func (*VerificationService) Send

func (s *VerificationService) Send(ctx context.Context, tenant model.Tenant, userID string) error

Send issues a token for an account and delivers it.

Called from registration, where the account has just been created and the caller is entitled to know whether it worked — unlike Resend below, which must not disclose anything.

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

Create registers a subscription and returns its signing secret.

func (*WebhookService) Delete

func (s *WebhookService) Delete(ctx context.Context, actor auth.Principal, id string) error

Delete removes a subscription and its delivery history.

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

func (s *WebhookService) SweepDeliveries(ctx context.Context, tenantID string, now time.Time) error

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL