Files
krow_backend/go-api/internal/domain/policy.go
2026-08-24 13:06:29 +05:30

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