Documentation
¶
Overview ¶
Package protocolauth is the single door through which every non-HTTP protocol gets its caller.
The failure it exists to prevent ¶
`tenant.FromContext` returns (nil, false) when no scope is set, and the package contract says absence means "unscoped — see everything" (tenant/context.go:40-47). That is correct for single-tenant installs and it is why the type was designed that way. The consequence is the problem: **an entry point that authenticates on its own starts unscoped**, and stays that way until a human remembers to attach a scope.
This is not hypothetical. It already happened on the one protocol we had shipped: /dav authenticates with HTTP Basic, so it sat outside the chain where auth.TenantResolver runs, ListEnabledStorages saw an unscoped context, and the root collection listed every tenant's storages. A tenant admin who mapped /dav got all ten olivov tenants read-write (H4, 2026-08-05).
S3, SFTP, FTP, NFS and FUSE all authenticate outside the HTTP middleware chain *by definition* — their credentials are not a session cookie. So /dav was not a mistake someone made; it was the first instance of a shape that reproduces once per protocol.
The rule ¶
A protocol handler must not be able to construct a caller by itself. There is one entry point, and it returns everything at once, so that "forgot to attach the scope" stops being expressible. The glue this replaces already existed and was merely scattered — auth.ScopeForUser, auth.LoginAllowed, acl.Resolver.LoadSet, confine — and a protocol that has to *remember* to call four things will eventually skip one.
What it deliberately does NOT do ¶
It does not decide whether a caller may perform an operation. That is Principal.ACL plus the per-protocol verb mapping, because the shape of a denial is protocol-specific: x/net/webdav maps filesystem errors to 404/405 and never 403, while S3 must answer NoSuchKey rather than AccessDenied or the endpoint becomes an existence oracle across tenants. Each protocol owns its refusal; none of them owns identity.
Index ¶
- Constants
- Variables
- func HashExportSecret(secret string) string
- func KickCredential(r *Resolver, kind CredKind, id int64)
- func SecretsFrom(key string) (*secretbox.Box, error)
- type ConfinePolicy
- type CredKind
- type Directory
- type IssueExportRequest
- type IssueRequest
- type IssuedExport
- type IssuedKey
- type LiveInfo
- type LiveSession
- type Principal
- type Resolver
- func (r *Resolver) AccessKey(ctx context.Context, accessKeyID string) (*Principal, string, error)
- func (r *Resolver) Admit(ctx context.Context, p *Principal, access perm.Perm, protocol string) (*Principal, error)
- func (r *Resolver) Any(ctx context.Context, identifier, secret string) (*Principal, error)
- func (r *Resolver) Enter(p *Principal, protocol, remote, login string, closer func()) *LiveSession
- func (r *Resolver) Export(ctx context.Context, path string, remote net.IP) (*Principal, *model.NFSExport, error)
- func (r *Resolver) Forget()
- func (r *Resolver) Issue(ctx context.Context, req IssueRequest) (*IssuedKey, error)
- func (r *Resolver) IssueExport(ctx context.Context, req IssueExportRequest) (*IssuedExport, error)
- func (r *Resolver) Kick(kind CredKind, id int64) int
- func (r *Resolver) Live() []LiveInfo
- func (r *Resolver) Password(ctx context.Context, identifier, password string) (*Principal, error)
- func (r *Resolver) PublicKey(ctx context.Context, identifier, fingerprint string) (*Principal, error)
- func (r *Resolver) Recheck(ctx context.Context, p *Principal) error
- func (r *Resolver) RunRevalidator(ctx context.Context, every time.Duration)
- func (r *Resolver) Sweep(ctx context.Context) int
- func (r *Resolver) Token(ctx context.Context, identifier, token string) (*Principal, error)
Constants ¶
const DefaultCacheTTL = 5 * time.Minute
DefaultCacheTTL matches what /dav has used since it shipped.
const DefaultRevalidate = 30 * time.Second
DefaultRevalidate is how often live sessions are re-checked. It is the upper bound on "how long does a revoked credential keep working on a connection that is already open", and it is deliberately the same order as the password cache TTL rather than something larger — one number, one answer.
Variables ¶
var ( // ErrNoSecretBox means no FILEX_SECRET_KEY is configured. Issuing fails // rather than storing the secret in the clear, because a credential // created "temporarily unencrypted" is one nobody goes back for. ErrNoSecretBox = errors.New("protocolauth: no secret key configured; set FILEX_SECRET_KEY to issue S3 access keys") // ErrWidensParent means the requested confinement is not inside the // confinement of the credential it is being minted from. ErrWidensParent = errors.New("protocolauth: an access key may narrow its parent's scope, never widen it") )
Errors the issuing surface distinguishes. Authentication still collapses to ErrUnauthorized — these are for the person creating a key, who is already authenticated and deserves to know what went wrong.
ErrUnauthorized is the only failure a protocol may report to the wire.
It is deliberately the single error: "no such account", "wrong password", "disabled account", "suspended tenant" and "that credential may not be used here" must be indistinguishable to a caller, or the endpoint becomes an account-enumeration oracle. Operators get the detail from the logs.
Functions ¶
func HashExportSecret ¶
HashExportSecret is how the path secret is stored and looked up.
sha256 and not bcrypt, deliberately: this is a bearer credential with 256 bits of entropy, so there is nothing to brute-force, and the lookup happens on every mount — a work factor would buy nothing and cost a hundred milliseconds. (The same reasoning api_tokens uses.)
func KickCredential ¶
KickCredential is the small helper the API handlers call after deleting or disabling a credential, so revocation is instant rather than merely bounded. It is a free function taking a possibly-nil resolver because the handlers hold one that is nil when no protocol listener is configured.
Types ¶
type ConfinePolicy ¶
type ConfinePolicy int
ConfinePolicy says what to do with a credential that carries a path confinement (an API token with a `root:` scope).
const ( // ConfineRefuse rejects such a credential outright. Correct for a protocol // that cannot enforce the restriction, because accepting one would silently // promote a subtree-limited credential to whole-tree access. This is what // /dav has always done (dav.go:186-190) and it is the safe default: a // protocol that has not yet been taught about confinement gets it for free. ConfineRefuse ConfinePolicy = iota // ConfineHonor accepts it and carries the Root on the Principal, for a // protocol that enforces it on every path. S3 is the natural case — an // access key's shape is already (bucket, prefix), which is IAM's own model. ConfineHonor )
type Directory ¶
type Directory interface {
VerifyPassword(ctx context.Context, identifier, password string) (*model.User, error)
}
Directory is an external password authority — today internal/auth/drivers/ ldap. It returns the LOCAL account the directory credentials resolve to (upserting it on first sight), and auth.ErrUnauthorized when the directory judged the credentials and said no.
⚠ The distinction matters: any other error means the directory could not judge (unreachable, TLS, bad service bind). A protocol must still answer ErrUnauthorized to the wire, but the operator has to be able to find the difference in the log.
type IssueExportRequest ¶
type IssueExportRequest struct {
User *model.User
Token *model.APIToken
Label string
// Storage/Prefix confine the mount. Empty Storage means every storage the
// account can see.
Storage string
Prefix string
// ReadOnly refuses every write through this export regardless of the
// account's own permission.
ReadOnly bool
// AllowCIDRs is a comma-separated allow-list of client addresses.
AllowCIDRs string
ExpiresAt *time.Time
}
IssueExportRequest is what the caller asked for.
type IssueRequest ¶
type IssueRequest struct {
// User is the account the key belongs to. Required.
User *model.User
// Token, when set, is the API token the key inherits from. The key then
// carries that token's scopes, its confinement and its expiry ceiling — it
// is the token projected into a protocol that cannot carry a bearer value,
// not a second, independent credential.
Token *model.APIToken
Label string
// Bucket/Prefix optionally narrow the key further, in the shape S3 clients
// already understand.
Bucket string
Prefix string
// ExpiresAt optionally expires the key. It is clamped to the parent
// token's expiry when there is one — a projection outliving its original
// would be a way to launder a short-lived credential into a long-lived one.
ExpiresAt *time.Time
}
IssueRequest describes a key to mint.
type IssuedExport ¶
type IssuedExport struct {
Export *model.NFSExport
// Path is the full export path, e.g. `/x/9f2c…`. It is the credential:
// it is not stored, only its hash is, and it cannot be recovered.
Path string
}
IssuedExport is a freshly minted export. The path is shown exactly once.
type IssuedKey ¶
type IssuedKey struct {
Key *model.S3AccessKey
// Secret is the plaintext. It exists only in this struct, only in memory,
// and only until the response is written: the stored copy is sealed, and no
// listing endpoint can ever recover it.
Secret string
}
IssuedKey is what the caller shows the user exactly once.
type LiveInfo ¶
type LiveInfo struct {
Protocol string
Remote string
Login string
UserID int64
Since time.Time
Revoked bool
}
LiveInfo is a snapshot of one session, for an operator view.
type LiveSession ¶
type LiveSession struct {
// Protocol is "sftp", "ftps", "nfs" — what the operator sees in a log line.
Protocol string
// Remote is the client address, Login what it typed. Both for the log.
Remote string
Login string
Since time.Time
// contains filtered or unexported fields
}
LiveSession is one registered connection.
func (*LiveSession) Leave ¶
func (ls *LiveSession) Leave()
Leave deregisters the session. Protocols defer it; calling it twice is fine.
func (*LiveSession) Principal ¶
func (ls *LiveSession) Principal() *Principal
Principal is the caller this session belongs to.
func (*LiveSession) Revoked ¶
func (ls *LiveSession) Revoked() bool
Revoked reports whether this session has been cut.
⚠ It exists for NFS, which cannot be hung up on: a mount is a state the client holds, not a connection the server can close, so the only way to stop serving it is for every operation to start refusing. Connection protocols do not need to consult this — their closer already ended the conversation — but checking it costs an atomic load and makes the refusal deterministic instead of a race with the TCP close.
type Principal ¶
type Principal struct {
// User is the account. Never nil on a successful resolve.
User *model.User
// Token is the API token used, or nil for password authentication.
Token *model.APIToken
// Scope is the tenant scope. NEVER nil when the install is multi-tenant —
// tenant.DenyAll when it could not be resolved, so an unresolvable caller
// sees nothing rather than everything.
Scope *tenant.Scope
// Confine is the path restriction the credential carries, or nil when it
// carries none. A protocol that asked for ConfineHonor MUST enforce it.
Confine *confine.Root
// AccessKey is the S3 credential this caller signed with, or nil for every
// other authentication route. Carried so the S3 layer can report the key in
// the audit trail without a second lookup.
AccessKey *model.S3AccessKey
// SSHKey is the registered public key this caller authenticated with, or
// nil. Export is the NFS export the mount was bound to, or nil.
//
// ⚠ These two are not decoration. Together with Token and AccessKey they
// are what a live session is re-checked against (see Recheck): a credential
// the Principal cannot name is a credential that cannot be revoked out from
// under an open connection.
SSHKey *model.SSHPublicKey
Export *model.NFSExport
// contains filtered or unexported fields
}
Principal is one resolved caller. Everything a protocol needs to be tenant- and permission-aware arrives together; nothing here can be forgotten, because nothing here has to be fetched.
func (*Principal) ACL ¶
ACL returns the caller's grant set for one storage, loading it at most once per TTL.
Caching here rather than per request is what makes the set usable by a stream protocol: an SFTP session issues thousands of stats, and LoadSet is a query.
⚠⚠ The TTL is not a performance knob, it is the answer to "I took away somebody's access — when does it stop working?". A Principal outlives one request: an SFTP or FTPS session is open for hours and a mount for days, so a set cached for the life of the Principal would mean a grant removed at 09:00 keeps serving files until the user logs out. It is the same number as the password cache (Resolver.CacheTTL) so there is ONE answer to that question rather than one per protocol.
func (*Principal) Allows ¶ added in v0.49.0
Allows reports whether the account holds per-user permission q (package perm). A resolver without a store (tests) answers yes; a resolution error answers no.
func (*Principal) ForgetACL ¶
func (p *Principal) ForgetACL()
ForgetACL drops the cached grant sets, so the next question hits the database. Called when a session is re-checked and the account changed under it — the alternative is waiting out the TTL twice.
func (*Principal) HasScope ¶
HasScope reports whether the credential grants an API-token verb scope. Password authentication carries every scope; a token consults its allow-list.
func (*Principal) WithContext ¶
WithContext stamps the request context with everything downstream code reads from it. Protocols call this once per request (or once per session, for stream protocols), and then behave exactly like an HTTP handler that went through the middleware chain.
⚠ Both halves matter and both have been forgotten before. Without the user, a write produces a node owned by nobody — so its bytes are never counted against a quota — and a file event with no actor, so the audit trail cannot say who did it (fixed for /dav in 2026-08-15). Without the scope, the caller sees every tenant.
type Resolver ¶
type Resolver struct {
Store db.Store
ACL *acl.Resolver
// MultiTenant mirrors config.MultiTenant. When true a Principal always
// carries a Scope.
MultiTenant bool
// Confine says how to treat a path-confined credential. The zero value is
// ConfineRefuse, which is the conservative direction on purpose.
Confine ConfinePolicy
// Secrets seals and opens the credentials that must be recoverable rather
// than hashed — today the S3 access keys, because SigV4 verifies by
// recomputing an HMAC chain. Nil (or keyless) means no key is configured,
// and issuing one then FAILS rather than storing plaintext.
Secrets *secretbox.Box
// Directory verifies a password that the local users table cannot judge —
// the LDAP/AD driver, when one is configured. Nil means "local passwords
// only", which is exactly what every install had before.
//
// ⚠ Why the protocols need this at all: a directory account is upserted
// into users with an EMPTY password_hash, because filex never learns the
// password. So the bcrypt branch below refuses it forever, and it refuses
// it identically to a wrong password — an AD user could sign in to the web
// UI and still be told 401 by WebDAV, SFTP, FTPS and S3 with nothing in the
// log to say why.
Directory Directory
// CacheTTL is how long a successful PASSWORD verification is remembered.
//
// ⚠ This number is a security statement: it is how long a revoked password
// keeps working. It exists because Basic-auth-style protocols present the
// password on EVERY request and bcrypt costs ~100ms — without a cache a
// WebDAV PROPFIND storm or an S3 client at a few hundred requests a second
// would spend all its time hashing. Zero disables the cache.
CacheTTL time.Duration
// contains filtered or unexported fields
}
Resolver turns credentials into principals.
func (*Resolver) AccessKey ¶
AccessKey resolves an access key id into its caller and the secret a signature must be verified against.
⚠ It returns the plaintext secret, and that is not an oversight: SigV4 gives the server a MAC, never the secret, so verification is only possible by recomputing the chain locally. The caller must use it for exactly that and must not log it, echo it, or keep it past the request.
⚠ It does NOT verify anything. A caller that forgets to check the signature has authenticated nobody — which is why the S3 layer's signature check and this lookup live next to each other and are tested together.
func (*Resolver) Admit ¶ added in v0.49.0
func (r *Resolver) Admit(ctx context.Context, p *Principal, access perm.Perm, protocol string) (*Principal, error)
Admit is the last step of every protocol login: the account must hold the protocol's access permission (access.webdav, access.sftp, …). Refused, it answers ErrUnauthorized like any other failed login — the wire must not learn that the password was right — and says why in the log.
⚠ Checked when a connection authenticates, so taking the permission away ends access at the next login (the "on reconnect" rule), not mid-session. File actions inside an open session are different: they are judged against the Principal's ACL set, which reloads every aclTTL — the same answer the grants have always had.
func (*Resolver) Any ¶
Any is the order the password-carrying protocols use: try the secret as an account password, then as an API token. It exists so every such protocol makes the same choice — the alternative is each one inventing its own order, and the order is observable (it decides which credential wins when a token happens to equal a password).
func (*Resolver) Enter ¶
func (r *Resolver) Enter(p *Principal, protocol, remote, login string, closer func()) *LiveSession
Enter registers a live session.
closer is how this protocol hangs up; nil is allowed and means "there is no connection to close" (NFS), in which case Revoked() is the whole mechanism.
func (*Resolver) Export ¶
func (r *Resolver) Export(ctx context.Context, path string, remote net.IP) (*Principal, *model.NFSExport, error)
Export resolves a mount path into its caller.
remote is the client's address, checked against the export's allow-list. It may be nil when there is no address to check (a test, or a transport that does not expose one), in which case an export WITH an allow-list is refused — a restriction that cannot be evaluated must not be treated as satisfied.
func (*Resolver) Forget ¶
func (r *Resolver) Forget()
Forget drops every cached password result. Called when a credential is revoked so the TTL is an upper bound, not the only bound.
func (*Resolver) Issue ¶
Issue mints an S3 access key.
The permission rule is enforced here rather than at the HTTP boundary, because "every credential we issue can connect, at exactly its permission" is a property of the credential, not of the form that created it. A key minted from a token cannot outlive it, cannot escape its confinement, and dies with it (the FK cascades).
func (*Resolver) IssueExport ¶
func (r *Resolver) IssueExport(ctx context.Context, req IssueExportRequest) (*IssuedExport, error)
IssueExport mints an export.
The permission rules are the same as an access key's, and enforced here for the same reason: "a credential can narrow but never widen" is a property of the credential rather than of the form that made it.
func (*Resolver) Kick ¶
Kick cuts every live session that authenticated with one credential, right now, without waiting for the sweep.
It returns how many were cut, which is the number worth logging: "deleted the token, closed 2 sessions" is a different sentence from "deleted the token", and only the first one tells the operator the job is finished.
func (*Resolver) Password ¶
Password resolves an identifier (e-mail OR username — identity.Resolve owns which) plus an account password.
⚠ An account with TOTP enabled is REFUSED here, on every protocol. None of these protocols has a second-factor channel, so accepting the password would make each of them a documented 2FA bypass: whoever knows the password gets in without ever meeting the second factor. Such an account must mint an API token (or, later, an access key or a registered public key) — credentials that are individually revocable, which is the whole point of the app-specific password pattern.
func (*Resolver) PublicKey ¶
func (r *Resolver) PublicKey(ctx context.Context, identifier, fingerprint string) (*Principal, error)
PublicKey resolves a registered SSH public key by fingerprint.
⚠ It does NOT prove possession — x/crypto/ssh has already verified the signature by the time this is called. What it decides is which account the key belongs to, and whether that account may log in at all.
⚠ Unlike Password, an account with TOTP enabled is ACCEPTED here, and that is deliberate: a registered key is a second, individually revocable credential — exactly the app-specific-password pattern TOTP accounts are pointed at. The key can be switched off from the account's own settings without touching the password.
func (*Resolver) Recheck ¶
Recheck asks the database whether a principal resolved earlier would still resolve now.
⚠ It re-reads every row rather than trusting the copies on the Principal — those are a photograph taken at login, and the whole point is to find out what has changed since. What it does NOT do is re-verify the secret: the password was never kept, the token's plaintext was never kept, and the SSH signature cannot be replayed. Possession was proven once; this checks that the credential still exists and still may be used.
func (*Resolver) RunRevalidator ¶
RunRevalidator sweeps on a timer until ctx is done. Started once, by the server, next to the other background services.
func (*Resolver) Sweep ¶
Sweep re-checks every live session and cuts the ones whose credential no longer resolves. Returns how many were cut.
func (*Resolver) Token ¶
Token resolves an API token. When identifier is non-empty the token must belong to the account it names — the protocols that carry a username field (WebDAV Basic, FTP USER, SFTP) should pass it, so a token pasted against the wrong account is refused rather than silently acting as its real owner.