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}}, }, // What a worker declares they do, as opposed to what the organization needs // filled — that is job-postings. Operators maintain the organization's; // talent reads their own and no one else's. // // Create is operators-only, and that is an I1 decision rather than a // deferral of one. The worker is named explicitly on the row and is // deliberately NOT derived from the session, because an operator recording // a role on somebody's behalf is the whole point of the flow. Granting // talent Create with the same shape would let a talent caller write a role // under any worker_email in the tenant, which is precisely the attribution // hole Phase 3D closed elsewhere. When a talent console exists, the grant // arrives together with a TalentOnly derivation of worker_email — one line, // not a migration, which is what the scope below is already in place for. "employee-roles": { List: everyone, Get: everyone, Create: operators, Update: operators, TalentScope: Scope{Kind: ScopeEmail, Column: "worker_email"}, Derived: []Derived{{Column: "created_by", Source: DeriveUserID}}, }, // 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] } }