assertPurpose($purpose); if ($subuserId <= 0) { throw new Exception('Invalid subuser action token subject'); } $token = bin2hex(random_bytes(self::TOKEN_BYTES)); $tokenHash = hash('sha256', $token); $ttlSeconds ??= $purpose === self::PURPOSE_PASSWORD_RESET ? self::PASSWORD_RESET_TTL_SECONDS : self::GRANT_DECISION_TTL_SECONDS; $expiresAt = gmdate('Y-m-d H:i:s', time() + max(60, $ttlSeconds)); $statement = $db->conn->prepare( 'INSERT INTO subuser_action_tokens ' . '(token_hash, purpose, subuser_id, grant_id, customer_number, expires_at) VALUES (?, ?, ?, ?, ?, ?)' ); if ($statement === false) { throw new Exception('Failed to prepare subuser action token'); } $statement->bind_param('ssiiis', $tokenHash, $purpose, $subuserId, $grantId, $customerNumber, $expiresAt); $statement->execute(); $statement->close(); return $token; } public function inspect(string $token, ?string $expectedPurpose = null): ?array { global $db; $token = strtolower($token); if (!preg_match('/^[a-f0-9]{64}$/', $token)) { return null; } if ($expectedPurpose !== null) { $this->assertPurpose($expectedPurpose); } $tokenHash = hash('sha256', $token); $sql = 'SELECT id, purpose, subuser_id, grant_id, customer_number, expires_at FROM subuser_action_tokens ' . "WHERE token_hash = '" . $db->escape_string($tokenHash) . "' " . 'AND used_at IS NULL AND expires_at > UTC_TIMESTAMP()'; if ($expectedPurpose !== null) { $sql .= " AND purpose = '" . $db->escape_string($expectedPurpose) . "'"; } $result = $db->query($sql . ' LIMIT 1'); if ($result === false || $result->num_rows === 0) { return null; } $row = $result->fetch_assoc(); return [ 'id' => (int)$row['id'], 'purpose' => (string)$row['purpose'], 'subuser_id' => (int)$row['subuser_id'], 'grant_id' => $row['grant_id'] === null ? null : (int)$row['grant_id'], 'customer_number' => $row['customer_number'] === null ? null : (int)$row['customer_number'], 'expires_at' => (string)$row['expires_at'], ]; } public function consume(string $token, string $expectedPurpose): ?array { global $db; $record = $this->inspect($token, $expectedPurpose); if ($record === null) { return null; } $statement = $db->conn->prepare( 'UPDATE subuser_action_tokens SET used_at = UTC_TIMESTAMP() ' . 'WHERE id = ? AND used_at IS NULL AND expires_at > UTC_TIMESTAMP()' ); if ($statement === false) { throw new Exception('Failed to consume subuser action token'); } $id = (int)$record['id']; $statement->bind_param('i', $id); $statement->execute(); $consumed = $statement->affected_rows === 1; $statement->close(); return $consumed ? $record : null; } public function consumeGrantDecision(string $token): ?array { global $db; $preview = $this->inspect($token); if ( $preview === null || $preview['grant_id'] === null || !in_array($preview['purpose'], [ self::PURPOSE_GRANT_APPROVE, self::PURPOSE_GRANT_DENY, ], true) ) { return null; } $db->conn->begin_transaction(); try { // Serializing on the grant row prevents simultaneous approve and // deny links from racing and applying opposite final states. $lock = $db->conn->prepare( 'SELECT id FROM subuser_grants ' . 'WHERE id = ? AND subuser = ? AND billing_customer_number = ? AND deleted_at IS NULL ' . 'FOR UPDATE' ); if ($lock === false) { throw new Exception('Failed to lock subuser grant decision'); } $grantId = (int)$preview['grant_id']; $subuserId = (int)$preview['subuser_id']; $customerNumber = (int)$preview['customer_number']; $lock->bind_param('iii', $grantId, $subuserId, $customerNumber); $lock->execute(); $lock->store_result(); $grantExists = $lock->num_rows === 1; $lock->close(); if (!$grantExists) { $db->conn->rollback(); return null; } $record = $this->consume($token, (string)$preview['purpose']); if ($record === null) { $db->conn->rollback(); return null; } $enabled = $record['purpose'] === self::PURPOSE_GRANT_APPROVE ? 1 : 0; $update = $db->conn->prepare('UPDATE subuser_grants SET enabled = ? WHERE id = ?'); if ($update === false) { throw new Exception('Failed to apply subuser grant decision'); } $update->bind_param('ii', $enabled, $grantId); $update->execute(); $applied = $update->affected_rows === 1 || $update->warning_count === 0; $update->close(); if (!$applied) { throw new Exception('Failed to apply subuser grant decision'); } $this->revokeGrantDecisions($grantId); $db->conn->commit(); return $record; } catch (Exception $exception) { $db->conn->rollback(); throw $exception; } } public function revokeForSubuser(int $subuserId, string $purpose): void { global $db; $this->assertPurpose($purpose); $statement = $db->conn->prepare( 'UPDATE subuser_action_tokens SET used_at = UTC_TIMESTAMP() WHERE subuser_id = ? AND purpose = ? AND used_at IS NULL' ); if ($statement === false) { throw new Exception('Failed to revoke subuser action tokens'); } $statement->bind_param('is', $subuserId, $purpose); $statement->execute(); $statement->close(); } public function revokeGrantDecisions(int $grantId): void { global $db; $statement = $db->conn->prepare( 'UPDATE subuser_action_tokens SET used_at = UTC_TIMESTAMP() ' . 'WHERE grant_id = ? AND purpose IN (?, ?) AND used_at IS NULL' ); if ($statement === false) { throw new Exception('Failed to revoke grant decision tokens'); } $approve = self::PURPOSE_GRANT_APPROVE; $deny = self::PURPOSE_GRANT_DENY; $statement->bind_param('iss', $grantId, $approve, $deny); $statement->execute(); $statement->close(); } private function assertPurpose(string $purpose): void { if (!in_array($purpose, [ self::PURPOSE_GRANT_APPROVE, self::PURPOSE_GRANT_DENY, self::PURPOSE_PASSWORD_RESET, ], true)) { throw new Exception('Invalid subuser action token purpose'); } } }