331 lines
13 KiB
Go
331 lines
13 KiB
Go
package domain
|
|
|
|
// Authorization policy: who may perform which operation on which resource, and
|
|
// which rows they may see when they get there.
|
|
//
|
|
// This file is hand-written and `resources_gen.go` is generated, which is the
|
|
// whole reason they are separate. Regenerating the descriptors from the live
|
|
// schema must never silently drop an access rule, and a column appearing in the
|
|
// database must never grant anybody anything by accident.
|
|
//
|
|
// Three properties hold here by construction:
|
|
//
|
|
// - DENY BY DEFAULT. A resource with no policy permits nothing, to anyone. A
|
|
// resource added to the schema tomorrow is unreachable until somebody
|
|
// writes down who may reach it. TestEveryResourceHasAPolicy makes the
|
|
// omission loud rather than silent.
|
|
// - ROLE IS users.role, ALWAYS. Never account_type — which the user can
|
|
// change on themselves through PATCH /me — and never anything read from a
|
|
// request body, a header or the browser.
|
|
// - OWNERSHIP IS A SQL PREDICATE, NOT A FILTER. TalentScope describes a WHERE
|
|
// clause the repository adds beside the organization scope. Rows a talent
|
|
// user may not see are never fetched, so they cannot leak through a count,
|
|
// a total or a bug in a later loop.
|
|
//
|
|
// Authorization is checked in the handler, before any query runs, and answers
|
|
// 403. Organization and ownership are predicates, so a row outside them is
|
|
// simply absent and answers 404 — the caller cannot tell "exists but not yours"
|
|
// from "does not exist", which is the point.
|
|
|
|
// Role is the authorization authority. It mirrors the users_role_check
|
|
// constraint in migration 000001 and there are deliberately no others.
|
|
type Role string
|
|
|
|
const (
|
|
RoleAdmin Role = "admin"
|
|
RoleEmployer Role = "employer"
|
|
RoleTalent Role = "talent"
|
|
)
|
|
|
|
// ParseRole converts a stored users.role into a Role, reporting whether it is
|
|
// one this API recognises. An unrecognised value authorizes nothing.
|
|
func ParseRole(s string) (Role, bool) {
|
|
switch Role(s) {
|
|
case RoleAdmin:
|
|
return RoleAdmin, true
|
|
case RoleEmployer:
|
|
return RoleEmployer, true
|
|
case RoleTalent:
|
|
return RoleTalent, true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
/* ── Row visibility ─────────────────────────────────────────────────────── */
|
|
|
|
// ScopeKind is how a resource decides which rows a talent user may see.
|
|
type ScopeKind int
|
|
|
|
const (
|
|
// ScopeNone: no extra predicate. Every row in the organization is visible.
|
|
ScopeNone ScopeKind = iota
|
|
// ScopeUserID: Column = the authenticated user's id.
|
|
ScopeUserID
|
|
// ScopeEmail: Column = the authenticated user's email.
|
|
//
|
|
// Used where the schema ties a row to a person by email string rather than
|
|
// by a foreign key — assignments, evidence, shift records, applications,
|
|
// activity. Those columns have no FK (see the Phase 3D audit, F-05), so the
|
|
// write path is what makes this trustworthy: a talent caller never supplies
|
|
// the value, it is derived from the session. See Derived.
|
|
ScopeEmail
|
|
// ScopeOwnApplications: the row references a job application belonging to
|
|
// the authenticated user. Ownership by reference rather than by column —
|
|
// an AI interview names an application, and the application names a person.
|
|
ScopeOwnApplications
|
|
// ScopeActivePostings: visibility rather than ownership. A talent user sees
|
|
// the postings they could apply to, not the organization's drafts, paused
|
|
// roles or closed history.
|
|
ScopeActivePostings
|
|
)
|
|
|
|
// Scope is the predicate applied to a talent caller's rows.
|
|
type Scope struct {
|
|
Kind ScopeKind
|
|
// Column is the column carrying the owner, for ScopeUserID and ScopeEmail.
|
|
// For ScopeOwnApplications it is the column referencing the application.
|
|
// For ScopeActivePostings it is the status column.
|
|
//
|
|
// Required by every kind except ScopeNone: a scope naming a column the
|
|
// resource does not have matches no rows at all, which is the safe
|
|
// direction to fail but is still a bug worth noticing.
|
|
Column string
|
|
}
|
|
|
|
/* ── Server-owned values ────────────────────────────────────────────────── */
|
|
|
|
// DeriveSource names which fact about the caller fills a column.
|
|
type DeriveSource int
|
|
|
|
const (
|
|
DeriveUserID DeriveSource = iota
|
|
DeriveEmail
|
|
DeriveFullName
|
|
DeriveAccountType
|
|
)
|
|
|
|
// Derived is a column the server fills in on insert from the session.
|
|
//
|
|
// Every column named here is also ReadOnly in the descriptors, so a value in a
|
|
// request body is dropped before it reaches SQL. This is the other half: the
|
|
// column still has to be filled, and the only acceptable source is the
|
|
// authenticated identity.
|
|
type Derived struct {
|
|
Column string
|
|
Source DeriveSource
|
|
// TalentOnly restricts the derivation to talent callers.
|
|
//
|
|
// It exists because two different questions wear the same shape. `created_by`
|
|
// and the user_activity columns record WHO ACTED, so they are the session
|
|
// user whoever that is. `worker_profiles.user_id`, `job_applications.email`
|
|
// and `evidence.worker_email` record WHO THE ROW IS ABOUT — and when an
|
|
// admin creates a candidate's profile or logs an application on their
|
|
// behalf, the subject is emphatically not the admin. Deriving those
|
|
// unconditionally would quietly file every candidate's record under the
|
|
// operator who typed it in.
|
|
TalentOnly bool
|
|
}
|
|
|
|
/* ── Policy ─────────────────────────────────────────────────────────────── */
|
|
|
|
// Policy is one resource's access rules.
|
|
//
|
|
// A nil Policy denies everything. An empty role list for an operation denies
|
|
// that operation to everyone, which is how an operation the resource does not
|
|
// support is expressed.
|
|
type Policy struct {
|
|
List []Role
|
|
Get []Role
|
|
Create []Role
|
|
Update []Role
|
|
Delete []Role
|
|
|
|
// TalentScope narrows which rows a talent caller may read or write. It is
|
|
// applied to talent and to nobody else: admin and employer see the whole
|
|
// organization, which is what an operator console is for.
|
|
TalentScope Scope
|
|
|
|
// Derived fills server-owned columns on insert.
|
|
Derived []Derived
|
|
}
|
|
|
|
// Allows reports whether a role may perform an operation.
|
|
//
|
|
// The zero answer is no: a nil policy, an unknown operation and an unlisted
|
|
// role all deny.
|
|
func (p *Policy) Allows(op Op, role Role) bool {
|
|
if p == nil {
|
|
return false
|
|
}
|
|
for _, r := range p.rolesFor(op) {
|
|
if r == role {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (p *Policy) rolesFor(op Op) []Role {
|
|
switch op {
|
|
case OpList:
|
|
return p.List
|
|
case OpGet:
|
|
return p.Get
|
|
case OpCreate:
|
|
return p.Create
|
|
case OpUpdate:
|
|
return p.Update
|
|
case OpDelete:
|
|
return p.Delete
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ScopeFor returns the row predicate that applies to a role. Only talent is
|
|
// scoped; every other role sees the organization.
|
|
func (p *Policy) ScopeFor(role Role) Scope {
|
|
if p == nil || role != RoleTalent {
|
|
return Scope{}
|
|
}
|
|
return p.TalentScope
|
|
}
|
|
|
|
/* ── The table ──────────────────────────────────────────────────────────── */
|
|
|
|
var (
|
|
everyone = []Role{RoleAdmin, RoleEmployer, RoleTalent}
|
|
// operators are the roles that run the organization's hiring and workforce:
|
|
// they see and act on the whole tenant. Talent is not one of them.
|
|
operators = []Role{RoleAdmin, RoleEmployer}
|
|
adminOnly = []Role{RoleAdmin}
|
|
)
|
|
|
|
// policies is the authorization contract, keyed by URL path.
|
|
//
|
|
// Read this table as the answer to "who may call this, and which rows do they
|
|
// get". It is the only place those two questions are answered.
|
|
var policies = map[string]*Policy{
|
|
|
|
// Postings are the organization's shop window. Operators author them;
|
|
// talent sees the ones that are open, and nothing else — not the drafts,
|
|
// not the paused roles, not the closed history.
|
|
"job-postings": {
|
|
List: everyone, Get: everyone,
|
|
Create: operators, Update: operators,
|
|
TalentScope: Scope{Kind: ScopeActivePostings, Column: "status"},
|
|
Derived: []Derived{{Column: "created_by", Source: DeriveUserID}},
|
|
},
|
|
|
|
// An application is written by a person about themselves. Talent may file
|
|
// one and read their own; only operators may move it through the funnel or
|
|
// remove it. A talent caller never supplies the email — it is the session's,
|
|
// which is what makes the read predicate below mean anything.
|
|
"job-applications": {
|
|
List: everyone, Create: everyone,
|
|
Update: operators, Delete: operators,
|
|
TalentScope: Scope{Kind: ScopeEmail, Column: "email"},
|
|
Derived: []Derived{{Column: "email", Source: DeriveEmail, TalentOnly: true}},
|
|
},
|
|
|
|
// An interview belongs to an application, and the application belongs to a
|
|
// person. Talent reads their own and may sit one for an application of
|
|
// theirs; the insert guard in the repository enforces the second half.
|
|
"ai-interviews": {
|
|
List: everyone, Create: everyone,
|
|
TalentScope: Scope{Kind: ScopeOwnApplications, Column: "application_id"},
|
|
},
|
|
|
|
// The employment record of the organization's workforce. Operators only:
|
|
// it carries endorsements, review dates and reviewer names, which are the
|
|
// organization's assessment of a person rather than the person's own data.
|
|
"staff": {
|
|
List: operators, Create: operators, Update: operators,
|
|
},
|
|
|
|
// A worker's own profile: contact details, address, salary expectations,
|
|
// personality assessment. Talent may read and maintain theirs and no other.
|
|
// user_id is server-owned, so a talent caller cannot claim someone else's
|
|
// profile by naming them, and cannot hand theirs away.
|
|
"worker-profiles": {
|
|
List: everyone, Create: everyone, Update: everyone,
|
|
TalentScope: Scope{Kind: ScopeUserID, Column: "user_id"},
|
|
Derived: []Derived{{Column: "user_id", Source: DeriveUserID, TalentOnly: true}},
|
|
},
|
|
|
|
// Who is on which position. Operators allocate; talent reads their own
|
|
// roster and cannot create one — being assigned to work is not a thing you
|
|
// do to yourself.
|
|
"assignments": {
|
|
List: everyone, Create: operators,
|
|
TalentScope: Scope{Kind: ScopeEmail, Column: "worker_email"},
|
|
},
|
|
|
|
// Attendance. Read-only for everyone over the API — the seeder owns these
|
|
// rows — and talent sees only their own shifts.
|
|
"shift-records": {
|
|
List: everyone,
|
|
TalentScope: Scope{Kind: ScopeEmail, Column: "worker_email"},
|
|
},
|
|
|
|
// The training library. Everyone learns from it; only admin authors it.
|
|
// Employer is excluded from authoring deliberately: courses with a NULL
|
|
// org_id are the shared platform library, visible to every tenant, so a
|
|
// write here can reach beyond the writer's own organization.
|
|
"courses": {
|
|
List: everyone, Get: everyone,
|
|
Create: adminOnly, Update: adminOnly,
|
|
},
|
|
|
|
"learning-paths": {List: everyone},
|
|
|
|
// Organization taxonomy: the role names positions are filed under.
|
|
"role-categories": {
|
|
List: everyone, Create: operators,
|
|
},
|
|
|
|
// Organization taxonomy: the certifications positions may require.
|
|
// Deleting one changes what every existing posting means, so it is admin's.
|
|
"certifications": {
|
|
List: everyone, Create: operators, Delete: adminOnly,
|
|
},
|
|
|
|
// The audit log. Append-only by schema (no update, no delete). Anyone may
|
|
// write an entry about themselves — and only about themselves: all four
|
|
// identity columns are server-derived, so an entry cannot be attributed to
|
|
// someone else. Operators read the organization's log; talent reads theirs.
|
|
"user-activity": {
|
|
List: everyone, Create: everyone,
|
|
TalentScope: Scope{Kind: ScopeEmail, Column: "user_email"},
|
|
Derived: []Derived{
|
|
{Column: "user_id", Source: DeriveUserID},
|
|
{Column: "user_email", Source: DeriveEmail},
|
|
{Column: "user_name", Source: DeriveFullName},
|
|
{Column: "account_type", Source: DeriveAccountType},
|
|
},
|
|
},
|
|
|
|
// Proof of work: a worker submits it, the organization verifies it.
|
|
// Talent may submit their own and read it back; the verdict is an operator
|
|
// judgement, so talent cannot PATCH.
|
|
"evidence": {
|
|
List: everyone, Create: everyone, Update: operators,
|
|
TalentScope: Scope{Kind: ScopeEmail, Column: "worker_email"},
|
|
Derived: []Derived{{Column: "worker_email", Source: DeriveEmail, TalentOnly: true}},
|
|
},
|
|
|
|
// Badges have no endpoints at all (Ops: 0 — the frontend's Badge.list call
|
|
// has 404ed since Phase 2C). The empty policy is written out rather than
|
|
// omitted so that the resource is deliberately closed rather than merely
|
|
// forgotten, and so TestEveryResourceHasAPolicy passes honestly.
|
|
"badges": {},
|
|
}
|
|
|
|
// init attaches the policies to the descriptors.
|
|
//
|
|
// A resource with no entry keeps a nil Policy and therefore permits nothing.
|
|
func init() {
|
|
for _, r := range AllResources {
|
|
r.Policy = policies[r.Path]
|
|
}
|
|
}
|