Documentation
¶
Overview ¶
Package identity owns the rules for account login names, and the single door that turns a typed identifier into a user.
Why this package exists ¶
filex accounts have always been addressed by e-mail. The protocols filex is growing do not carry an e-mail comfortably: `sftp://ada@example.com@host` needs escaping, rclone and WinSCP config files split on `@`, and FTP's USER command with an `@` in it confuses proxying clients. So accounts get a second, short identifier — and every surface accepts EITHER (the owner's words, translated from Turkish: "wherever we can, let's look at both the e-mail and the username — think of it like a dual-side login").
"Every surface" is the load-bearing half. A login rule that lives in one handler is a login rule that is subtly different in the next one, and the difference is invisible until a user reports that the same credentials work in the browser and fail over SFTP. So resolution lives here, once, and the surfaces call Resolve.
The disambiguation rule ¶
An identifier containing `@` is an e-mail; anything else is a username. The lookup NEVER tries both.
That is a security rule, not a tidiness one. If resolution fell back from one to the other, a username could be chosen to collide with the local part of someone else's e-mail (or vice versa), and which account answered would depend on row order and on which query happened to run first. Usernames are therefore forbidden from containing `@` (Validate rejects it), which makes the two namespaces provably disjoint and the rule total: every identifier has exactly one interpretation.
Index ¶
- Constants
- Variables
- func CheckOSLogin(ctx context.Context, raw string, checker SystemAccountChecker) (name, refusal string)
- func ClaimBootstrap(ctx context.Context, c Claimer, u *model.User) error
- func DeriveEmail(name, domain, realm, token string) string
- func EmailToken(raw string) (string, error)
- func EnsureUsername(ctx context.Context, c Claimer, u *model.User) (string, error)
- func ForbiddenAccount(name string) bool
- func LoginNameOf(email, realm, token string) (string, bool)
- func LooksLikeEmail(identifier string) bool
- func Names(u *model.User, identifier string) bool
- func NamesIn(u *model.User, identifier, realm, token string) bool
- func Normalize(identifier string) string
- func Resolve(ctx context.Context, l Lookup, identifier string) (*model.User, error)
- func ResolveIn(ctx context.Context, l Lookup, t *Tenant, identifier string) (*model.User, error)
- func Suggest(email string) string
- func SuggestName(email, lang string) string
- func TokenDomain(realm, token string) string
- func Validate(username string) error
- type Claimer
- type Lookup
- type Problem
- type SystemAccountChecker
- type Tenant
Constants ¶
const ( // RefuseInvalidName: the OS name cannot be a filex username (too short, // starts with a digit, a character a protocol login cannot carry, or one of // the names identity reserves). RefuseInvalidName = "invalid_name" // RefuseForbidden: a system or service account — root, Administrator, // daemons, the accounts an OS creates for its own services. RefuseForbidden = "forbidden_account" )
Why a login name is refused. They are the operator's words in the audit row and the log, never the person's: a person is told only that the sign-in did not work.
const ( MinLen = 3 MaxLen = 32 )
Bounds for a username. 32 is also the width of the MySQL column, so the validator and the schema cannot disagree.
const ( ProblemEmpty = "empty" ProblemAt = "at" ProblemShort = "short" ProblemLong = "long" ProblemUpper = "upper" ProblemDigit = "digit" ProblemChar = "char" ProblemReserved = "reserved" )
Problem kinds Check answers. They are the rule's names for the HTTP layer and the browser, which each say them in the reader's language; the English sentence Validate builds is for logs and the CLI.
const BootstrapUsername = "admin"
BootstrapUsername is the login name of the administrator filex creates at first run — the one account allowed to hold a reserved name.
const DefaultEmailToken = "local"
DefaultEmailToken is what follows the `@` of an account that has a login name and no e-mail domain: `alex` becomes `alex@local`.
Variables ¶
var ( ErrInvalidUsername = errors.New("invalid username") ErrReservedUsername = errors.New("reserved username") ErrNotFound = errors.New("no such account") )
Sentinel errors. Callers match on these; the wrapped text is what a person reads, and the API boundary is where it gets translated.
var ErrBadEmailToken = errors.New("identity: the e-mail token must be letters, digits, dots and dashes")
ErrBadEmailToken is returned for an e-mail token that cannot follow an `@`.
Functions ¶
func CheckOSLogin ¶ added in v0.50.0
func CheckOSLogin(ctx context.Context, raw string, checker SystemAccountChecker) (name, refusal string)
CheckOSLogin turns the name an operating system reported into the filex username it will be, or names why it cannot be one.
- the name is folded to lower case (a Windows `Alex` is the filex `alex`);
- it must satisfy Check — at least MinLen characters, no leading digit, the protocol-safe character set, not one of the reserved names — because the username is what SFTP, FTPS and S3 see;
- system and service accounts are refused in every case, by name (ForbiddenAccount) and then by what the OS says (checker, may be nil).
The refusal is one of RefuseInvalidName / RefuseForbidden; on success it is "". Nothing here ever repairs a name (no transliteration, no truncation): a login that quietly became another name would sign one person into another's account.
func ClaimBootstrap ¶
ClaimBootstrap gives the first-run administrator the username "admin".
⚠ The owner's decision (2026-09-22): the first administrator of a new install is "admin", not "admin2". "admin" is reserved so that nobody else can take it — a later `admin@…` account still becomes "admin2" through EnsureUsername, and no person can rename themselves to it (Check refuses it) — but the bootstrap account is the one the reservation was made FOR. Every screen named it "admin2" because EnsureUsername's collision loop stepped past the reserved name.
Only server.FirstRun calls this, on an install with no accounts. Existing installs are not touched: an "admin2" stays "admin2" (no migration). A name already held by another account is left alone and reported.
func DeriveEmail ¶ added in v0.50.0
DeriveEmail is the e-mail address an account gets when a provider knows a login name and, possibly, a domain:
- a name that already contains `@` is an address: returned folded, as it is;
- with a domain (the machine's or directory's own, "corp.example"): the person's address, `alex@corp.example` — the same in every realm;
- with no domain: `alex@<token>` for the platform's own tenant (and every single-tenant install), `alex@<realm>.<token>` for a tenant's realm (`alex@acme.local`) — token from EmailToken, `local` unless the administrator chose another at installation.
⚠ The realm is what lets two tenants each have an `alex` on a multi-tenant install: without it both were `alex@local`, and e-mail addresses are unique across the platform, so the second one could never get an account.
realm is the tenant's realm ("" = the platform's own tenant, or no tenants at all); token is used as given when non-empty (call EmailToken first to validate an operator's value); "" means DefaultEmailToken.
func EmailToken ¶ added in v0.50.0
EmailToken normalises the operator's e-mail token (FILEX_OS_LOGIN_EMAIL_TOKEN): "" is DefaultEmailToken, a leading `@` is dropped, the rest is folded to lower case and must be DNS-label-like.
⚠⚠ The token is chosen ONCE, at installation, by the administrator — from the environment, never from the admin panel. It is part of every account's e-mail address, and an address is an account's identity: change the token later and `alex` signs in as `alex@evim` while the `alex@local` account (with its files, shares and quota) sits beside it. Two accounts for one person is the outcome this design exists to prevent, so nothing in filex edits it live.
func EnsureUsername ¶
EnsureUsername assigns a username to an account that has none, derived from its e-mail, and returns the name it claimed. It is idempotent: an account that already has one is left alone, and re-running the backfill after an interruption renames nobody.
Uniqueness is settled here rather than in Suggest because only this function can see the database. The suffix is a plain counter appended to the derived name — deterministic given the same order of arrival, and recognisable to its owner, which a random string would not be.
func ForbiddenAccount ¶ added in v0.50.0
ForbiddenAccount reports whether a login name belongs to a system or service account by name. The name is compared folded (case, surrounding space).
func LoginNameOf ¶ added in v0.50.0
LoginNameOf reads DeriveEmail backwards for a name that came with no domain: the login name an address `<name>@<token>` (or `<name>@<realm>.<token>` in that realm) was made from. ok is false for any other address (an address of another domain is a real mailbox, not one filex made up — and so is another realm's) and for a bare name. realm and token are read as DeriveEmail reads them.
⚠ It exists for the file protocols. They ask a provider with the account's e-mail — the only name SFTP's username field and the account share — so a provider that looks people up by login name (an LDAP filter on `uid`) is handed back `alex@acme.local` and has to ask its directory about `alex`.
func LooksLikeEmail ¶
LooksLikeEmail reports whether an identifier should be resolved as an e-mail. The test is deliberately the crudest one that is total: usernames cannot contain `@` (Validate enforces it), so the presence of one is decisive. A stricter parse would only add ways for a valid address to be misread.
func Names ¶
Names reports whether the identifier addresses this account — as its e-mail or as its username, case-insensitively.
It exists for the surfaces that have already resolved a user by some other route and now need to confirm the typed identifier belongs to them: the WebDAV credential cache (is the cached account still the one being named?) and the WebDAV API-token path (does the Basic username name the token's owner?). Comparing only the e-mail there would let a credential work in the browser and fail over the protocol, which is the per-surface drift this package exists to prevent.
func NamesIn ¶ added in v0.50.0
NamesIn is Names in a realm: the identifier may also be the login name the account's derived address was made from there (`alex` names `alex@acme.local` in realm acme). realm and token are read as DeriveEmail reads them.
func Normalize ¶
Normalize folds an identifier to its canonical form for lookup: trimmed and lowercased. Both namespaces are case-insensitive — a user who types `Ada` into FileZilla means `ada`, and an e-mail address is already stored lower.
func Resolve ¶
Resolve turns what a person typed into an account: e-mail or username, one interpretation each (see the package comment for why it never tries both).
This is the ONLY function a login surface should call. The browser session, the desktop app, WebDAV Basic, FTPS, SFTP and the protocol credential pages all go through here, so "which identifiers work" has exactly one answer instead of one answer per surface.
Returns ErrNotFound when there is no such account — including when the identifier is not even a well-formed username, because "no account" is the only thing a login surface may reveal about a failed lookup.
func ResolveIn ¶ added in v0.50.0
ResolveIn is Resolve for a sign-in that names a tenant (its realm): the lookup never leaves that tenant — an account of another tenant is not found, whatever the identifier. The order (the owner's decision):
- the address the provider would have derived for a login name in this realm (`alex` in realm acme → `alex@acme.local`, DeriveEmail) — what a directory or operating-system account is keyed by;
- the username, inside the tenant (usernames stay unique across the whole platform, so a tenant's `alex` may hold `alex2`; step 1 is what finds it by the name the person knows);
- the address as typed, when an address was typed (step 1 and 3 are then the same lookup).
⚠ A candidate in another tenant is SKIPPED, not returned and not an error: "that name is somebody else's in another tenant" and "no such account" must read the same to whoever typed it.
func Suggest ¶
Suggest derives a candidate username from an e-mail address. It is deterministic: the same address always yields the same candidate, so a backfill that is interrupted and re-run does not rename anybody.
Uniqueness is NOT this function's job — Suggest can and will return a name that is already taken, including a reserved one. Claiming is EnsureUsername's job, because only it can see the database. That split is deliberate: Suggest never invents an unrelated name to dodge a collision.
func SuggestName ¶ added in v0.54.0
SuggestName derives a display name from an e-mail address, for a form that fills one in while the address is typed: "jane.doe@corp.com" → "Jane Doe".
The local part is split at dots, underscores, hyphens and pluses, and each word gets a capital first letter in the reader's language (`lang`, a BCP 47 tag; "" is the root rules) - "ismail" is "İsmail" to a Turkish reader. What follows a plus is a mail tag (ada+filex@…), not part of the name, the same rule Suggest applies to the username. The rest of each word is kept as it was typed.
⚠ Here and not in the browser (filex #211, audit B4): the admin form used to make both suggestions itself, and its username rule turned "gözlük" into "g.zl.k" where Suggest - the rule an SSO account's first sign-in gets - gives "gozluk". One address, two usernames.
func TokenDomain ¶ added in v0.50.0
TokenDomain is what follows the `@` of an address DeriveEmail makes up for a login name with no domain: the token, or `<realm>.<token>` in a tenant's realm.
Types ¶
type Claimer ¶
type Claimer interface {
Lookup
SetUserUsername(ctx context.Context, id int64, username string) error
}
Claimer additionally assigns a username. Used by account creation and by the one-time backfill.
type Lookup ¶
type Lookup interface {
GetUserByEmail(ctx context.Context, email string) (*model.User, error)
GetUserByUsername(ctx context.Context, username string) (*model.User, error)
}
Lookup is the slice of db.Store this package needs to resolve an identifier. Declared here rather than imported so identity stays a leaf package that the db drivers, the auth drivers and every protocol server can all depend on.
type Problem ¶
type Problem struct {
Kind string
Char rune // ProblemChar: the first character that is not allowed
Limit int // ProblemShort / ProblemLong: MinLen or MaxLen
}
Problem is why a username is refused, in parts: the kind, and the one detail the sentence needs (the character, or the limit).
func Check ¶
Check reports why a username is not acceptable, nil when it is.
The character set is ASCII on purpose, and this is the one place in filex where that is the right answer: a username travels through SSH USER records, FTP command lines, S3 key labels and shell config files, all of which mangle non-ASCII differently. The DISPLAY name is where a person's name belongs, and it has always accepted the full alphabet.
⚠ The browser checks the same rules while a person types (packages/core/src/lib/accountRules.ts); this is the authority, that is the mirror. A rule added here must be added there, or the form lets through a name the save then refuses.
type SystemAccountChecker ¶ added in v0.50.0
type SystemAccountChecker interface {
IsSystemAccount(ctx context.Context, name string) (bool, error)
}
SystemAccountChecker is what an OS sign-in provider implements to say what only the operating system knows about an account: a Linux UID below 1000, a nologin/false shell, a Windows service SID.
⚠ It is asked AFTER the name checks, and a checker that cannot answer (the account database is unreadable) must return an error: CheckOSLogin refuses on error. The other way round — letting an account through because nobody could say it was a service account — is how root would get a filex login.
type Tenant ¶ added in v0.50.0
type Tenant struct {
// ProviderID is the tenant's row; 0 only for a platform with no
// supertenant row at all.
ProviderID int64
// Realm is the tenant's realm for derived addresses (DeriveEmail); "" for
// the platform's own tenant.
Realm string
// Main: this is the platform's own tenant, and an account with no tenant
// (provider_id NULL — a bootstrap or legacy row) belongs to it.
Main bool
// Token is the installation's e-mail token (EmailToken; "" = default).
Token string
}
Tenant limits an account lookup to one tenant: the realm a sign-in is for on a multi-tenant install (auth.LoginRealm builds it). A nil *Tenant is "no tenants" — ResolveIn is then exactly Resolve.