Documentation
¶
Overview ¶
Package backoffice is the back office the apps share: who may use an app (roles, access states), the session check that enforces it on every request, the admin routes over the accounts, View as, the announcement shown to everyone, the features an admin keeps to the admins, the audit log and the account stats. schema.sql lists the columns and the tables it needs beside lib's auth tables and audit_events.
Every app signs in with a 6-digit code sent by email: RequestCode and VerifyCode, drawn by the kit's SignIn.svelte. Another way to prove an address (a password, a provider) calls SignIn. Every handler that needs a signed-in user calls Check (or RequireAdmin); HandleMe and HandleSignOut serve /me and sign out. Mount serves the admin routes under /api/v1/admin/ and the features under /api/v1/features; the UI kit (lib's web/admin, web/features.svelte.ts) draws them.
Index ¶
- Constants
- Variables
- func IsPending(accessState, pendingSince string) bool
- type AdminUser
- type Announcement
- type Backoffice
- func (b *Backoffice) AccountStats(ctx context.Context, now time.Time) (Stats, error)
- func (b *Backoffice) ActiveAnnouncement(ctx context.Context) (*Announcement, error)
- func (b *Backoffice) Admit(ctx context.Context, tx *sql.Tx, email string, now time.Time) (User, string, error)
- func (b *Backoffice) AuditSignIn(ctx context.Context, tx *sql.Tx, u User, joined, via, ip string) error
- func (b *Backoffice) Check(w http.ResponseWriter, r *http.Request) (Session, bool)
- func (b *Backoffice) CheckAllowPending(w http.ResponseWriter, r *http.Request) (Session, bool)
- func (b *Backoffice) EndSession(w http.ResponseWriter, r *http.Request) (found bool, err error)
- func (b *Backoffice) Features(ctx context.Context) ([]FeatureState, error)
- func (b *Backoffice) HandleMe(w http.ResponseWriter, r *http.Request)
- func (b *Backoffice) HandleSignOut(w http.ResponseWriter, r *http.Request)
- func (b *Backoffice) IsSuperAdmin(role, email string) bool
- func (b *Backoffice) ListUsers(ctx context.Context, connectedOnly bool) ([]AdminUser, error)
- func (b *Backoffice) MeOf(ctx context.Context, sess Session) (Me, error)
- func (b *Backoffice) Mount(mux *http.ServeMux)
- func (b *Backoffice) RequestCode(w http.ResponseWriter, r *http.Request)
- func (b *Backoffice) RequireAdmin(w http.ResponseWriter, r *http.Request) (Session, bool)
- func (b *Backoffice) SignIn(w http.ResponseWriter, r *http.Request, email, via string, ...) (User, error)
- func (b *Backoffice) VerifyCode(w http.ResponseWriter, r *http.Request)
- type Config
- type Feature
- type FeatureState
- type LoggedEvent
- type Me
- type Session
- type Stats
- type User
Constants ¶
const ( AccessActive = "active" AccessRestrained = "restrained" AccessPending = "pending" )
Access states. The database keeps a pending account restrained with pending_since set: AccessPending only exists in the API.
const ( AudienceEveryone = "everyone" AudienceAdmins = "admins" )
Audiences of a feature: everyone, or the admins only, as for a beta.
const ( JoinedAdminEmail = "admin_email" JoinedFirstUser = "first_user" JoinedOpen = "open" JoinedUnknown = "unknown_email" )
Why a sign-in created an account, as the signed_up audit row and log line say it.
const ( // CodeTTL is how long a sign-in code stays valid. CodeTTL = 15 * time.Minute )
The sign-in by a 6-digit code sent by email. The codes live in lib's magic_links table (auth/schema 002 and the attempts column of 004) as an HMAC of the email and the code, under a key made at start and never stored: a copy of the database cannot give away an open code, and a restart voids them.
const RoleAdmin = "admin"
RoleAdmin is the role of the accounts that use the back office; every app has it.
Variables ¶
var ( // ErrRestrained refuses the sign-in of an account an admin restrained. ErrRestrained = errors.New("user_restrained") )
Functions ¶
Types ¶
type AdminUser ¶
type AdminUser struct {
User
LastSeenAt string `json:"last_seen_at"`
ActiveSessionCount int64 `json:"active_session_count"`
Connected bool `json:"connected"`
}
AdminUser is an account in the admin list. Connected means it has a live session of its own (not a View as).
type Announcement ¶
type Announcement struct {
Text string `json:"text"`
Tone string `json:"tone"`
UpdatedAt string `json:"updated_at"`
}
Announcement is the banner an admin shows to every active account.
type Backoffice ¶
type Backoffice struct {
// contains filtered or unexported fields
}
Backoffice serves the back office of one app.
func (*Backoffice) AccountStats ¶
AccountStats counts the accounts at now; last seen leaves out the sessions of a View as.
func (*Backoffice) ActiveAnnouncement ¶
func (b *Backoffice) ActiveAnnouncement(ctx context.Context) (*Announcement, error)
ActiveAnnouncement returns the announcement shown now, or nil when there is none.
func (*Backoffice) Admit ¶
func (b *Backoffice) Admit(ctx context.Context, tx *sql.Tx, email string, now time.Time) (User, string, error)
Admit returns the account of a sign-in that proved email, in the transaction that creates its session:
- a new account is created: an admin when its email is a super admin's or the database has no account yet (or nobody could ever approve anyone), else the member role, active unless Config.Approval keeps it pending;
- a super admin is made an admin again at every sign-in;
- an account an admin restrained gets ErrRestrained.
joined says why the account was created, "" for an existing one. email must be normalized.
func (*Backoffice) AuditSignIn ¶
func (b *Backoffice) AuditSignIn(ctx context.Context, tx *sql.Tx, u User, joined, via, ip string) error
AuditSignIn writes signed_up first when the sign-in created the account, then signed_in, in the sign-in transaction. via names the sign-in method.
func (*Backoffice) Check ¶
func (b *Backoffice) Check(w http.ResponseWriter, r *http.Request) (Session, bool)
Check returns the session of an active account, or answers 401 (no valid session), 403 access_pending (an account waiting for an admin, who keeps the cookie) or 403 read_only_impersonation (a write while viewing the app as someone else) and returns false.
func (*Backoffice) CheckAllowPending ¶
func (b *Backoffice) CheckAllowPending(w http.ResponseWriter, r *http.Request) (Session, bool)
CheckAllowPending is Check that also lets an account waiting for an admin through: for /me and sign out.
func (*Backoffice) EndSession ¶
func (b *Backoffice) EndSession(w http.ResponseWriter, r *http.Request) (found bool, err error)
EndSession signs the request out and clears its cookie: a View as goes back to the admin's own session, any other session is revoked with a signed_out audit row. found is false when the request had no valid session.
func (*Backoffice) Features ¶ added in v0.5.0
func (b *Backoffice) Features(ctx context.Context) ([]FeatureState, error)
Features returns the features of Config.Features in their order, with their audience. An app that declares none reads nothing, so it needs no features table.
func (*Backoffice) HandleMe ¶ added in v0.8.0
func (b *Backoffice) HandleMe(w http.ResponseWriter, r *http.Request)
HandleMe is the handler of GET /api/v1/me: the Me of the session, a pending account's included; 401 without one.
func (*Backoffice) HandleSignOut ¶ added in v0.8.0
func (b *Backoffice) HandleSignOut(w http.ResponseWriter, r *http.Request)
HandleSignOut is the handler of POST /api/v1/sign-out: EndSession, then 204; 401 when there was no session.
func (*Backoffice) IsSuperAdmin ¶
func (b *Backoffice) IsSuperAdmin(role, email string) bool
IsSuperAdmin reports whether an account is an admin whose email is in Config.SuperAdmins.
func (*Backoffice) ListUsers ¶
ListUsers returns every account, the pending ones first, then the newest; connectedOnly keeps the connected ones.
func (*Backoffice) Mount ¶
func (b *Backoffice) Mount(mux *http.ServeMux)
Mount serves the admin routes, each needing an admin session, and GET /api/v1/features, which needs none.
func (*Backoffice) RequestCode ¶ added in v0.8.0
func (b *Backoffice) RequestCode(w http.ResponseWriter, r *http.Request)
RequestCode is the handler of POST /api/v1/sign-in/code, body {"email"}: it emails a new sign-in code, which voids the previous one, and answers 204. A restrained account gets no code and the same 204. Five codes per address in 15 minutes, then 429 too_many_codes; when the mailer is a blackhole the code is logged as "login code (dev)".
func (*Backoffice) RequireAdmin ¶
func (b *Backoffice) RequireAdmin(w http.ResponseWriter, r *http.Request) (Session, bool)
RequireAdmin is Check for the admins only: anyone else gets 403 forbidden.
func (*Backoffice) SignIn ¶ added in v0.8.0
func (b *Backoffice) SignIn(w http.ResponseWriter, r *http.Request, email, via string, also func(tx *sql.Tx, u User, joined string) error) (User, error)
SignIn signs the request in as email, an address the app has proven (by a password, a provider): in one transaction it admits the account (Admit), runs Config.OnSignIn then also (when not nil) with the account and why it was created ("" for an existing one), opens a session and writes the audit rows (AuditSignIn, with via), then sets the session cookie. A restrained account gets ErrRestrained and no cookie; an error of also or OnSignIn rolls the sign-in back and is returned as is.
func (*Backoffice) VerifyCode ¶ added in v0.8.0
func (b *Backoffice) VerifyCode(w http.ResponseWriter, r *http.Request)
VerifyCode is the handler of POST /api/v1/sign-in/verify, body {"email","code"}: with the open code of email it signs in (SignIn, via "code") and answers 204. A wrong code answers 400 invalid_or_expired_code; the 5th wrong one closes the code and answers too_many_attempts; a restrained account gets 403 user_restrained.
type Config ¶
type Config struct {
DB *sql.DB
// Roles an admin can give, RoleAdmin included; MemberRole is the role of a new account that is not an admin.
Roles []string
MemberRole string
// SuperAdmins are the normalized emails of the app's admin flag: always admins, protected from the other
// admins, the only ones who can view the app as someone else.
SuperAdmins map[string]struct{}
// Approval makes a new account wait for an admin unless Admit lets it in; without it every account is active.
Approval bool
// Admit, with Approval, lets a new account in at once and says why ("" keeps it pending); it runs in the
// sign-in transaction.
Admit func(ctx context.Context, tx *sql.Tx, email string) (string, error)
// Actions are the app's own audit codes, listed after audit.AccountActions in the audit log filter.
Actions []string
// Sessions is the session lifetime policy the check refreshes sessions with.
Sessions sessions.SessionPolicy
// Features are the parts of the app an admin can keep to the admins, in the order the back office lists them.
Features []Feature
// OnSignIn runs in every sign-in transaction (a code, SignIn) right after Admit, before the session exists: the
// app's own steps, such as dropping a password set before the address was proven. An error refuses the sign-in.
OnSignIn func(ctx context.Context, tx *sql.Tx, u User, joined, via string) error
// AppName names the app in the email that carries a sign-in code.
AppName string
// CodeEmail writes that email (subject, text, html) in the app's language; nil writes it in English.
CodeEmail func(code string, ttl time.Duration) (subject, text, html string)
}
Config is what an app decides about its accounts.
type Feature ¶ added in v0.5.0
Feature is a part of the app an admin can keep to the admins; the app's UI hides it from everyone else. Name is the key the UI tests, Label what the back office shows.
type FeatureState ¶ added in v0.5.0
FeatureState is a feature with its audience: AudienceEveryone until an admin chooses.
type LoggedEvent ¶
type LoggedEvent struct {
audit.Event
Actor string `json:"actor"`
TargetLabel string `json:"target_label"`
}
LoggedEvent is an audit event with its actor and target named for a person: emails for accounts.
type Me ¶
type Me struct {
UserID string `json:"user_id"`
Email string `json:"email"`
Role string `json:"role"`
AccessState string `json:"access_state"`
ImpersonatedBy string `json:"impersonated_by,omitempty"`
CanImpersonate bool `json:"can_impersonate"`
Announcement *Announcement `json:"announcement,omitempty"`
}
Me is /me: the account, its API access state, who views the app as this user, whether the user may View as, and the announcement to show (only to an active account). An app with more to say embeds it in its own answer.
type Session ¶
type Session struct {
ID string
UserID string
Email string
Role string
AccessState string // as stored: active or restrained
PendingSince string
// ImpersonatorID is the super admin viewing the app as this user, "" for a normal session.
ImpersonatorID string
CreatedAt string
ExpiresAt string
LastSeenAt string
}
Session is the session of a request, with its account.
type Stats ¶
type Stats struct {
Total int `json:"total"`
ByRole map[string]int `json:"by_role"`
Active24h int `json:"active_24h"`
Active7d int `json:"active_7d"`
NeverBack int `json:"never_seen_since_sign_up"`
New7d int `json:"new_7d"`
Pending int `json:"pending"`
Restrained int `json:"restrained"`
}
Stats counts the accounts: by role and access, new and active over the last day and week.
type User ¶
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Role string `json:"role"`
AccessState string `json:"access_state"` // the API value: active, restrained or pending
PendingSince string `json:"-"`
CreatedAt string `json:"created_at"`
}
User is an account as the back office sees it.