conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

variables.js (5676B)


      1 // src/lib/variables.js - per project variables
      2 //
      3 // Values are sealed with the secret box, bound to their project and name so
      4 // a row copied elsewhere will not open. They are injected into a job's
      5 // environment when it is claimed, and never written into the jobs table, so
      6 // the only place a secret rests is this one.
      7 //
      8 // Masked variables are also redacted from job logs. The worker does that
      9 // before anything leaves the host; the conductor repeats it on ingest as a
     10 // backstop against a worker that does not.
     11 
     12 const NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
     13 
     14 export function createVariables({ db, secrets, ttl = 30000 }) {
     15   // Decrypting on every log append would be wasteful, so the masked values
     16   // for a project are cached briefly.
     17   const maskCache = new Map();
     18 
     19   function aad(projectId, name) {
     20     return `project:${projectId}/variable:${name}`;
     21   }
     22 
     23   function open(row) {
     24     return secrets.open(row.value, aad(row.project_id, row.name));
     25   }
     26 
     27   return {
     28     async list(projectId) {
     29       const rows = await db.all(
     30         `SELECT project_id, name, value, masked, created_at
     31            FROM project_variables WHERE project_id = {project} ORDER BY name`,
     32         { project: projectId }
     33       );
     34       // Values are never listed, only whether they are masked.
     35       return rows.map((row) => ({
     36         name: row.name,
     37         masked: row.masked === 1,
     38         created_at: row.created_at,
     39         plaintext_at_rest: secrets.isPlaintext(row.value),
     40       }));
     41     },
     42 
     43     // Unlike resolve, a value that cannot be opened throws rather than
     44     // reading as absent, or a rotated key would look like a missing variable.
     45     async reveal(projectId, name) {
     46       const row = await db.get(
     47         `SELECT project_id, name, value, masked FROM project_variables
     48           WHERE project_id = {project} AND name = {name}`,
     49         { project: projectId, name }
     50       );
     51       if (!row) return null;
     52       return { name: row.name, masked: row.masked === 1, value: open(row) };
     53     },
     54 
     55     async set(projectId, name, value, { masked = true } = {}) {
     56       if (!NAME_PATTERN.test(String(name))) {
     57         throw new Error(`variable name ${JSON.stringify(name)} is not a valid environment variable name`);
     58       }
     59       if (typeof value !== 'string') throw new Error('variable value must be a string');
     60       if (value.length > 8192) throw new Error('variable value must be at most 8192 characters');
     61 
     62       const sealed = secrets.seal(value, aad(projectId, name));
     63 
     64       // No portable upsert, so the read and the write happen together.
     65       await db.transaction(async (tx) => {
     66         const existing = await tx.get(
     67           'SELECT name FROM project_variables WHERE project_id = {project} AND name = {name}',
     68           { project: projectId, name }
     69         );
     70         if (existing) {
     71           await tx.run(
     72             `UPDATE project_variables SET value = {value}, masked = {masked}
     73               WHERE project_id = {project} AND name = {name}`,
     74             { project: projectId, name, value: sealed, masked: masked ? 1 : 0 }
     75           );
     76         } else {
     77           await tx.run(
     78             `INSERT INTO project_variables (project_id, name, value, masked, created_at)
     79              VALUES ({project}, {name}, {value}, {masked}, {now})`,
     80             { project: projectId, name, value: sealed, masked: masked ? 1 : 0, now: Date.now() }
     81           );
     82         }
     83       });
     84 
     85       maskCache.delete(projectId);
     86       return { name, masked };
     87     },
     88 
     89     async remove(projectId, name) {
     90       const res = await db.run(
     91         'DELETE FROM project_variables WHERE project_id = {project} AND name = {name}',
     92         { project: projectId, name }
     93       );
     94       maskCache.delete(projectId);
     95       return res.changes > 0;
     96     },
     97 
     98     // Decrypted, for injection into a job environment.
     99     async resolve(projectId) {
    100       const rows = await db.all(
    101         'SELECT project_id, name, value, masked FROM project_variables WHERE project_id = {project}',
    102         { project: projectId }
    103       );
    104 
    105       const env = {};
    106       const masked = [];
    107       for (const row of rows) {
    108         let value;
    109         try {
    110           value = open(row);
    111         } catch {
    112           // A value that cannot be opened is skipped rather than failing
    113           // the job, since the rest of the pipeline may not need it.
    114           continue;
    115         }
    116         env[row.name] = value;
    117         if (row.masked === 1) masked.push(value);
    118       }
    119       return { env, masked };
    120     },
    121 
    122     // Just the values that must not appear in a log.
    123     async maskedValues(projectId) {
    124       const cached = maskCache.get(projectId);
    125       if (cached && cached.expires > Date.now()) return cached.values;
    126 
    127       const { masked } = await this.resolve(projectId);
    128       maskCache.set(projectId, { values: masked, expires: Date.now() + ttl });
    129       return masked;
    130     },
    131 
    132     invalidate(projectId) {
    133       if (projectId) maskCache.delete(projectId);
    134       else maskCache.clear();
    135     },
    136   };
    137 }
    138 
    139 // Replaces known secrets in a buffer. Used by the conductor on ingest as a
    140 // backstop; it works within a chunk only, since the worker is responsible
    141 // for handling values split across chunk boundaries.
    142 export function maskBuffer(buffer, values) {
    143   if (!values || values.length === 0) return buffer;
    144 
    145   let text = buffer.toString('utf8');
    146   let changed = false;
    147   // Longest first, so an overlapping shorter value cannot partially reveal
    148   // a longer one.
    149   for (const value of [...values].sort((a, b) => b.length - a.length)) {
    150     if (value.length < 4 || !text.includes(value)) continue;
    151     text = text.split(value).join('[masked]');
    152     changed = true;
    153   }
    154 
    155   return changed ? Buffer.from(text, 'utf8') : buffer;
    156 }