> ## Documentation Index
> Fetch the complete documentation index at: https://docs.endorlabs.com/llms.txt
> Use this file to discover all available pages before exploring further.

# User attribution

> Record the developer behind each Package Firewall request instead of the shared API key.

export const LicenseBadge = ({sku, skus, relation = 'any'}) => {
  const DATA_URL = '/snippets/license-sku-data.json';
  const CACHE_KEY = 'license-sku-data';
  const CACHE_TTL_MS = 60 * 60 * 1000;
  const REGISTRY_KEY = '__licenseSkuRegistry';
  const FALLBACK_LICENSES_URL = '/introduction/licenses';
  const ACCENT = '#26D07C';
  const CONJUNCTION = {
    any: 'or',
    all: 'and'
  };
  const FONT_STACK = '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif';
  const [isDark, setIsDark] = useState(false);
  const [data, setData] = useState(null);
  const [hasFetchError, setHasFetchError] = useState(false);
  const skuList = useMemo(() => {
    if (Array.isArray(skus) && skus.length > 0) {
      return skus.filter(code => typeof code === 'string' && code.trim()).map(c => c.trim());
    }
    if (typeof sku === 'string' && sku.trim()) {
      return [sku.trim()];
    }
    return [];
  }, [sku, skus]);
  useEffect(() => {
    const check = () => {
      const root = document.documentElement;
      setIsDark(root.dataset.theme === 'dark' || root.classList.contains('dark'));
    };
    check();
    const observer = new MutationObserver(check);
    observer.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ['data-theme', 'class']
    });
    return () => observer.disconnect();
  }, []);
  useEffect(() => {
    let cancelled = false;
    const readCache = () => {
      try {
        const raw = sessionStorage.getItem(CACHE_KEY);
        if (!raw) return null;
        const parsed = JSON.parse(raw);
        if (Date.now() - parsed.ts > CACHE_TTL_MS) {
          sessionStorage.removeItem(CACHE_KEY);
          return null;
        }
        return parsed.data;
      } catch (e) {
        return null;
      }
    };
    const writeCache = value => {
      try {
        sessionStorage.setItem(CACHE_KEY, JSON.stringify({
          ts: Date.now(),
          data: value
        }));
      } catch (e) {}
    };
    const fetchSkuData = async () => {
      const cached = readCache();
      if (cached) return cached;
      if (!globalThis[REGISTRY_KEY]) {
        globalThis[REGISTRY_KEY] = {};
      }
      const registry = globalThis[REGISTRY_KEY];
      if (registry[CACHE_KEY]) return registry[CACHE_KEY];
      const promise = (async () => {
        const resp = await fetch(DATA_URL);
        if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
        const json = await resp.json();
        writeCache(json);
        return json;
      })();
      registry[CACHE_KEY] = promise;
      promise.finally(() => {
        delete registry[CACHE_KEY];
      });
      return promise;
    };
    fetchSkuData().then(d => {
      if (!cancelled) setData(d);
    }).catch(() => {
      if (!cancelled) setHasFetchError(true);
    });
    return () => {
      cancelled = true;
    };
  }, []);
  const textColor = isDark ? '#e6edf3' : '#1f2937';
  const textMuted = isDark ? 'rgba(230,237,243,0.65)' : 'rgba(31,41,55,0.65)';
  const bannerBackground = isDark ? '#161b22' : '#f6f8fa';
  const borderColor = isDark ? 'rgba(38,208,124,0.35)' : 'rgba(38,208,124,0.45)';
  const linkColor = isDark ? '#4ade80' : '#047857';
  const errorBackground = isDark ? '#3b1111' : '#fef2f2';
  const errorBorder = isDark ? '#7f1d1d' : '#fecaca';
  const errorText = isDark ? '#fecaca' : '#7f1d1d';
  const licensesUrl = data?.licensesPageUrl || FALLBACK_LICENSES_URL;
  const resolveSkuName = code => {
    const entry = data?.skus?.[code];
    if (entry?.name) return entry.name;
    return null;
  };
  const formatSkuLabel = code => {
    const name = resolveSkuName(code);
    if (name) return name;
    return code;
  };
  const joinWithConjunction = (codes, conjunction) => {
    const labels = codes.map(formatSkuLabel);
    if (labels.length === 0) return '';
    if (labels.length === 1) return labels[0];
    if (labels.length === 2) return `${labels[0]} ${conjunction} ${labels[1]}`;
    const head = labels.slice(0, -1).join(', ');
    const tail = labels[labels.length - 1];
    return `${head}, ${conjunction} ${tail}`;
  };
  const buildSkuSentence = codes => {
    const conj = CONJUNCTION[relation] || CONJUNCTION.any;
    const names = joinWithConjunction(codes, conj);
    const noun = codes.length > 1 ? 'licenses' : 'license';
    return `${names} ${noun}`;
  };
  const renderLink = text => <a href={licensesUrl} className="lic-link" style={{
    color: linkColor,
    fontSize: '0.75rem',
    fontWeight: 500,
    textDecoration: 'none',
    whiteSpace: 'nowrap'
  }}>
      {text} →
    </a>;
  const renderBanner = codes => <div className="lic-banner not-prose" style={{
    margin: '1rem 0',
    padding: '0.5rem 0.85rem',
    background: bannerBackground,
    border: `1px solid ${borderColor}`,
    borderLeft: `3px solid ${ACCENT}`,
    borderRadius: '6px',
    color: textColor,
    fontSize: '0.75rem',
    display: 'flex',
    alignItems: 'center',
    gap: '0.5rem',
    flexWrap: 'wrap',
    fontFamily: FONT_STACK,
    lineHeight: 1.5
  }}>
      <span style={{
    flex: 1,
    minWidth: 0
  }}>
        <span style={{
    color: textMuted,
    marginRight: '0.3rem'
  }}>Requires</span>
        <span style={{
    fontWeight: 600
  }}>{buildSkuSentence(codes)}</span>
      </span>
      {renderLink('Licenses')}
    </div>;
  const renderLoading = () => <div className="not-prose" style={{
    margin: '1rem 0',
    padding: '0.6rem 0.9rem',
    background: bannerBackground,
    border: `1px dashed ${borderColor}`,
    borderRadius: '6px',
    color: textMuted,
    fontSize: '0.85rem',
    fontStyle: 'italic',
    fontFamily: FONT_STACK
  }} role="status" aria-live="polite">
      Loading license info…
    </div>;
  const renderInputError = message => <div className="not-prose" style={{
    margin: '1rem 0',
    padding: '0.6rem 0.9rem',
    background: errorBackground,
    border: `1px solid ${errorBorder}`,
    borderRadius: '6px',
    color: errorText,
    fontSize: '0.85rem',
    fontFamily: FONT_STACK
  }} role="alert">
      {message}
    </div>;
  if (typeof sku === 'string' && Array.isArray(skus)) {
    return renderInputError('LicenseBadge: pass either `sku` or `skus`, not both.');
  }
  if (skuList.length === 0) {
    return renderInputError('LicenseBadge: `sku` or `skus` is required.');
  }
  if (hasFetchError) return renderBanner(skuList);
  if (!data) return renderLoading();
  return renderBanner(skuList);
};

export const YamlTable = ({children, data: propData, content}) => {
  const KV_RE = /^([A-Za-z][A-Za-z0-9_()/#\s-]+?):\s*(.+)$/;
  const INLINE_MD_RE = /(\[([^\]]+)\]\(([^)]+)\))|(`([^`]+)`)|(\*\*([^*]+)\*\*)|(\*([^*]+)\*)/g;
  const YES_RE = /^-yes-$/i;
  const NO_RE = /^-no-$/i;
  const LIMITED_RE = /^-(limited|partial)-$/i;
  const NA_RE = /^-(na|none)-$/i;
  const NA2_RE = /^-na2-$/i;
  const SIMPLE_TAG_RE = /(<br\s*\/?>)|(<p\s*\/?>)|(-note-)|(-warning-)/gi;
  const renderSuccessIcon = () => <svg viewBox="0 0 24 24" width="100%" height="100%" aria-hidden="true">
      <path d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2ZM10 17L5 12L6.41 10.59L10 14.17L17.59 6.58L19 8L10 17Z" fill="currentColor" />
    </svg>;
  const renderFailureIcon = () => <svg viewBox="0 0 24 24" width="100%" height="100%" aria-hidden="true">
      <path fillRule="evenodd" clipRule="evenodd" d="M11.9902 1.98633C12.5208 1.9864 13.0426 2.12743 13.501 2.39453C13.9581 2.66107 14.3372 3.04388 14.5986 3.50391L22.5967 17.5L22.6895 17.6738C22.8921 18.0851 22.9979 18.5387 22.9981 18.999C22.9981 19.5253 22.8605 20.0431 22.5977 20.499C22.3348 20.9549 21.9555 21.3332 21.5 21.5967C21.0445 21.8601 20.5272 21.9994 20.001 22H4.00001C3.47453 22.0031 2.95699 21.868 2.50001 21.6084C2.04032 21.3471 1.65712 20.9684 1.39063 20.5117C1.12431 20.055 0.983643 19.5355 0.982429 19.0068C0.981281 18.4793 1.11967 17.9611 1.38282 17.5039L9.38184 3.50391C9.64344 3.04359 10.023 2.66111 10.4805 2.39453C10.9388 2.12755 11.4598 1.98636 11.9902 1.98633ZM12 16.9004C11.3925 16.9004 10.9004 17.3925 10.9004 18C10.9004 18.6075 11.3925 19.0996 12 19.0996H12.0098C12.6173 19.0996 13.1104 18.6075 13.1104 18C13.1104 17.3925 12.6173 16.9004 12.0098 16.9004H12ZM12 5.90039C11.3925 5.9004 10.9004 6.39249 10.9004 7V13C10.9004 13.6075 11.3925 14.0996 12 14.0996C12.6075 14.0996 13.0996 13.6075 13.0996 13V7C13.0996 6.39249 12.6075 5.90039 12 5.90039Z" fill="currentColor" />
    </svg>;
  const renderPartialSuccessIcon = () => <svg viewBox="0 0 24 24" width="100%" height="100%" aria-hidden="true">
      <path d="M12 1C18.0751 1 23 5.92487 23 12C23 18.0751 18.0751 23 12 23C5.92487 23 1 18.0751 1 12C1 5.92487 5.92487 1 12 1ZM12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3ZM12 5C13.8565 5 15.6374 5.73705 16.9502 7.0498C18.263 8.36256 19 10.1435 19 12C19 13.8565 18.263 15.6374 16.9502 16.9502C15.6374 18.263 13.8565 19 12 19C11.4477 19 11 18.5523 11 18V6C11 5.73478 11.1054 5.4805 11.293 5.29297C11.4805 5.10543 11.7348 5 12 5Z" fill="currentColor" />
    </svg>;
  const renderPendingIcon = () => <svg viewBox="0 0 24 24" width="100%" height="100%" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
      <path d="M10.1 2.182a10 10 0 0 1 3.8 0" />
      <path d="M13.9 21.818a10 10 0 0 1-3.8 0" />
      <path d="M17.609 3.721a10 10 0 0 1 2.69 2.7" />
      <path d="M2.182 13.9a10 10 0 0 1 0-3.8" />
      <path d="M20.279 17.609a10 10 0 0 1-2.7 2.69" />
      <path d="M21.818 10.1a10 10 0 0 1 0 3.8" />
      <path d="M3.721 6.391a10 10 0 0 1 2.7-2.69" />
      <path d="M6.391 20.279a10 10 0 0 1-2.69-2.7" />
    </svg>;
  const renderRunningIcon = () => <svg className="yt-status-running-svg" viewBox="22 22 44 44" width="100%" height="100%" aria-hidden="true">
      <circle className="yt-status-running-circle" cx="44" cy="44" r="20.2" fill="none" stroke="currentColor" strokeWidth="3.6" />
    </svg>;
  const renderSkippedIcon = () => <svg viewBox="0 0 24 24" width="100%" height="100%" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <circle cx="12" cy="12" r="10" />
      <path d="M8 12h8" />
    </svg>;
  const STATUS_ICON_DEFS = [{
    re: /^-status-success-$/i,
    colorClass: 'yt-status-color-success',
    label: 'Success',
    render: renderSuccessIcon
  }, {
    re: /^-status-failure-$/i,
    colorClass: 'yt-status-color-failure',
    label: 'Failure',
    render: renderFailureIcon
  }, {
    re: /^-status-partial-success-$/i,
    colorClass: 'yt-status-color-partial',
    label: 'Partial success',
    render: renderPartialSuccessIcon
  }, {
    re: /^-status-pending-$/i,
    colorClass: 'yt-status-color-neutral',
    label: 'Pending',
    render: renderPendingIcon
  }, {
    re: /^-status-running-$/i,
    colorClass: 'yt-status-color-neutral',
    label: 'Running',
    render: renderRunningIcon
  }, {
    re: /^-status-skipped-$/i,
    colorClass: 'yt-status-color-neutral',
    label: 'Skipped',
    render: renderSkippedIcon
  }];
  const renderStatusBadge = def => <span className={['yt-status-icon', def.colorClass].join(' ')} role="img" aria-label={def.label} title={def.label}>
      {def.render()}
    </span>;
  const tryParseKV = trimmed => {
    const m = KV_RE.exec(trimmed);
    return m ? {
      key: m[1],
      value: m[2].trim()
    } : null;
  };
  const registerKey = (key, seenKeys, orderedKeys) => {
    if (!seenKeys.has(key)) {
      orderedKeys.push(key);
      seenKeys.add(key);
    }
  };
  const flushEntry = (currentEntry, entries) => {
    if (Object.keys(currentEntry).length > 0) entries.push(currentEntry);
  };
  const parseDashPrefixed = (lines, entries, orderedKeys, seenKeys) => {
    let currentEntry = {};
    let inEntry = false;
    for (const line of lines) {
      const trimmed = line.trim();
      if (trimmed.startsWith('- ')) {
        if (inEntry) entries.push(currentEntry);
        currentEntry = {};
        inEntry = true;
        const kv = tryParseKV(trimmed.substring(2).trim());
        if (kv) {
          registerKey(kv.key, seenKeys, orderedKeys);
          currentEntry[kv.key] = kv.value;
        }
      } else if (inEntry && trimmed !== '') {
        const kv = tryParseKV(trimmed);
        if (kv) {
          registerKey(kv.key, seenKeys, orderedKeys);
          currentEntry[kv.key] = kv.value;
        }
      }
    }
    flushEntry(currentEntry, entries);
  };
  const parseBlankSeparated = (lines, entries, orderedKeys, seenKeys) => {
    let currentEntry = {};
    let inEntry = false;
    for (const line of lines) {
      const trimmed = line.trim();
      if (trimmed === '') {
        if (inEntry) {
          flushEntry(currentEntry, entries);
          currentEntry = {};
          inEntry = false;
        }
        continue;
      }
      const kv = tryParseKV(trimmed);
      if (!kv) continue;
      const isNewEntry = !line.startsWith(' ') && !line.startsWith('\t');
      if (isNewEntry && inEntry && Object.keys(currentEntry).length > 0) {
        entries.push(currentEntry);
        currentEntry = {};
      }
      registerKey(kv.key, seenKeys, orderedKeys);
      currentEntry[kv.key] = kv.value;
      inEntry = true;
    }
    flushEntry(currentEntry, entries);
  };
  const normalizeEntries = (entries, orderedKeys) => entries.map(entry => {
    const filled = {};
    for (const key of orderedKeys) filled[key] = entry[key] || '';
    return filled;
  });
  const parseYamlTableContent = contentStr => {
    if (!contentStr) return [];
    const entries = [];
    const orderedKeys = [];
    const seenKeys = new Set();
    const lines = contentStr.split('\n');
    if (lines.some(line => line.trim().startsWith('- '))) {
      parseDashPrefixed(lines, entries, orderedKeys, seenKeys);
    } else {
      parseBlankSeparated(lines, entries, orderedKeys, seenKeys);
    }
    return normalizeEntries(entries, orderedKeys);
  };
  const processText = text => {
    if (!text) return text;
    const parts = [];
    let keyIndex = 0;
    let lastIndex = 0;
    let match;
    while ((match = INLINE_MD_RE.exec(text)) !== null) {
      if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index));
      if (match[1]) {
        parts.push(<a key={keyIndex++} href={match[3]}>{match[2]}</a>);
      } else if (match[4]) {
        parts.push(<code key={keyIndex++}>{match[5]}</code>);
      } else if (match[6]) {
        parts.push(<strong key={keyIndex++}>{match[7]}</strong>);
      } else if (match[8]) {
        parts.push(<em key={keyIndex++}>{match[9]}</em>);
      }
      lastIndex = match.index + match[0].length;
    }
    if (lastIndex < text.length) parts.push(text.slice(lastIndex));
    if (parts.length === 0) return text;
    const keyRef = {
      current: keyIndex
    };
    return expandHtmlTags(parts, keyRef);
  };
  const processBadges = text => {
    if (!text || typeof text !== 'string') return text;
    if (YES_RE.test(text)) return <span className="yt-badge-yes" role="img" aria-label="Supported" title="Supported">✓</span>;
    if (NO_RE.test(text)) return <span className="yt-badge-no" role="img" aria-label="Not supported" title="Not supported">✗</span>;
    if (LIMITED_RE.test(text)) return <span className="yt-badge-limited" role="img" aria-label="Partially supported" title="Partially supported">◐</span>;
    if (NA_RE.test(text) || NA2_RE.test(text)) return <span className="yt-sr-only" title="Not applicable">Not applicable</span>;
    const statusBadge = STATUS_ICON_DEFS.find(def => def.re.test(text));
    if (statusBadge) return renderStatusBadge(statusBadge);
    return processText(text);
  };
  const cellClassName = text => {
    if (!text || typeof text !== 'string') return undefined;
    if (NA_RE.test(text)) return 'yt-cell-na';
    if (NA2_RE.test(text)) return 'yt-cell-na2';
    return undefined;
  };
  const expandSimpleTags = (str, keyRef) => {
    const result = [];
    let last = 0;
    SIMPLE_TAG_RE.lastIndex = 0;
    let m;
    while ((m = SIMPLE_TAG_RE.exec(str)) !== null) {
      if (m.index > last) result.push(str.slice(last, m.index));
      if (m[1]) {
        result.push(<br key={keyRef.current++} />);
      } else if (m[2]) {
        result.push(<br key={keyRef.current++} />, <br key={keyRef.current++} />);
      } else if (m[3]) {
        result.push(<span key={keyRef.current++} className="yt-badge-note" style={{
          fontWeight: 600
        }}>Note: </span>);
      } else if (m[4]) {
        result.push(<span key={keyRef.current++} className="yt-badge-warning" style={{
          fontWeight: 600
        }}>Warning: </span>);
      }
      last = m.index + m[0].length;
    }
    if (last < str.length) result.push(str.slice(last));
    return result;
  };
  const expandHtmlTags = (chunks, keyRef) => {
    const out = [];
    for (const chunk of chunks) {
      if (typeof chunk === 'string') {
        out.push(...expandSimpleTags(chunk, keyRef));
      } else {
        out.push(chunk);
      }
    }
    return out;
  };
  const extractText = node => {
    if (node === null || node === undefined) return '';
    if (typeof node === 'string') return node;
    if (typeof node === 'number') return String(node);
    if (typeof node === 'boolean') return '';
    if (Array.isArray(node)) return node.map(extractText).join('');
    if (node && typeof node === 'object' && node.type) {
      const props = node.props || ({});
      if (typeof props.children === 'string') return props.children;
      if (props.children) return extractText(props.children);
      return '';
    }
    return String(node || '');
  };
  const [mounted, setMounted] = useState(false);
  const scrollWrapRef = useRef(null);
  const [isScrollable, setIsScrollable] = useState(false);
  useEffect(() => {
    setMounted(true);
  }, []);
  const data = useMemo(() => {
    if (propData) return propData;
    if (content && typeof content === 'string') return parseYamlTableContent(content);
    if (!children) return [];
    if (typeof children === 'string') return parseYamlTableContent(children);
    const childrenArray = Array.isArray(children) ? children : [children];
    return parseYamlTableContent(childrenArray.map(extractText).join('').trim());
  }, [children, propData, content]);
  const columns = useMemo(() => {
    if (!data || data.length === 0) return [];
    const firstRow = data[0];
    if (!firstRow || typeof firstRow !== 'object') return [];
    return Object.keys(firstRow);
  }, [data]);
  useEffect(() => {
    const wrap = scrollWrapRef.current;
    if (!wrap) return undefined;
    const updateScrollable = () => {
      setIsScrollable(wrap.scrollWidth > wrap.clientWidth);
    };
    updateScrollable();
    const observer = new ResizeObserver(updateScrollable);
    observer.observe(wrap);
    return () => observer.disconnect();
  }, [mounted, data]);
  if (!mounted) return null;
  if (!data || data.length === 0) return null;
  const rowKey = row => columns.map(c => row[c] || '').join('|');
  return <div ref={scrollWrapRef} style={{
    overflowX: 'auto',
    margin: '1.75rem 0'
  }} role={isScrollable ? 'region' : undefined} aria-label={isScrollable ? 'Scrollable table' : undefined} tabIndex={isScrollable ? 0 : undefined}>
      <table style={{
    display: 'table',
    width: '100%',
    minWidth: '100%',
    margin: 0
  }}>
        <thead>
          <tr>
            {columns.map(col => <th key={col}>{col.replaceAll('_', ' ')}</th>)}
          </tr>
        </thead>
        <tbody>
          {data.map(row => <tr key={rowKey(row)}>
              {columns.map(col => <td key={col} className={cellClassName(row[col])}>{processBadges(row[col])}</td>)}
            </tr>)}
        </tbody>
      </table>
    </div>;
};

<LicenseBadge sku="EL-OSS-FWAL" />

Package Firewall authenticates every request with one API key, so each event in the logs names that key rather than a person. User attribution adds a label to the credentials on each request, and Package Firewall records that label as the user on the event.

Attribution is informational. Your API key ID and secret still control access. The label does not grant or restrict any permission, and rotating the key does not change how attribution works.

You can view the attributed user on each event and query the logs by attributed user. See [View Package Firewall logs](/package-firewall/logs) to learn more.

## How the label reaches Package Firewall

A client carries the label in the Basic-auth username, using the following format. The password stays your API secret.

```text theme={null}
base64(base64("userattr:" + <label>) + ":" + <api-key-id>)
```

Package Firewall decodes the username, restores your API key ID to authenticate the request, and records the label as the user on the event. The inner base64 layer keeps the label separate from the API key ID, so the label itself can contain any character.

Package Firewall treats a username that fails to decode, or one whose decoded value does not begin with `userattr:`, as a plain API key ID. The request authenticates exactly as it would without attribution, and the log records no attributed user.

Attribution happens at the credential layer, so any client that can vary the username per developer can attribute its traffic.

## Where attribution is available

<YamlTable>
  {`
    - Deployment: MDM deployment
    Attribution: -yes-
    How_the_label_is_set: The generated scripts build a label on each machine and encode it into every package manager client they configure
    - Deployment: Direct integration
    Attribution: -yes-
    How_the_label_is_set: You encode the label into the username you write to each package manager configuration file
    - Deployment: JFrog Artifactory
    Attribution: -yes-
    How_the_label_is_set: A worker rewrites the outgoing authorization header for each request, using the Artifactory user who made it
    - Deployment: VS Code extensions
    Attribution: -no-
    How_the_label_is_set: The extension gallery authenticates with a path token that is the same on every machine
    `}
</YamlTable>

Sonatype Nexus Repository and Google Artifact Registry send the credentials you stored on the repository with every request, so requests through those integrations carry no per-developer label.

Endor Patches requests are served from a different route than the firewall, so attribution does not apply to them.

## Attribute requests from MDM deployments

The MDM scripts attribute traffic by default. They build a `<console-user>@<machine>` label at install time and encode it into the credentials they write for npm, pnpm, yarn, bun, pip, uv, Go, Maven, and NuGet on macOS and Linux. See [MDM deployment](/package-firewall/mdm-deployment) to generate and deploy the scripts.

To attribute traffic differently, for example by email address or by an asset tag from your device inventory, fork the [MDM scripts repository](https://github.com/endorlabs/mdm-scripts) and change the `ENDOR_ATTR_LABEL` assignment. It lives in `package-firewall/bash/generate.sh` for macOS and Linux, and in `package-firewall/powershell/templates/envvars.ps1` for Windows.

The two environment variables the scripts write hold the same value at two encoding stages, not two halves of the label. `ENDOR_ATTR_LABEL` holds the entire label, and `ENDOR_ATTR_USER` holds the Basic-auth username derived from it. Package Firewall never combines them, and the attributed user in the logs is always exactly the label.

## Attribute requests from JFrog Artifactory

A remote repository stores one set of credentials and sends them on every request, so every developer looks the same to Package Firewall. A worker closes that gap. It runs inside Artifactory on each request, reads the Artifactory user who made the request, and rewrites the outgoing authorization header to carry that user as the label.

### Before you begin

* **Workers service**: Your JFrog subscription must include the Workers service. For availability, refer to [Workers overview](https://docs.jfrog.com/administration/docs/workers-overview).
* **Individual developer accounts**: Each developer must authenticate to Artifactory with their own account or token. The label comes from the Artifactory user on the request, so developers who share one service account all report the same label.
* **A configured remote repository**: Set up the repository first. See [Configure the Package Firewall with JFrog Artifactory](/package-firewall/jfrog-artifactory) to learn more.
* **An Endor Labs API key**: Use the key ID and secret you created for the remote repository.

### Reduce caching so requests reach Package Firewall

Artifactory serves a cached response without contacting Package Firewall, and a request that never arrives is never attributed. On each remote repository you route through Package Firewall, select **Advanced** and set **Metadata Retrieval Cache Period (Sec)** and **Missed Retrieval Cache Period (Sec)** to `0`, which disables that caching.

<Note>
  Some Artifactory cloud deployments enforce a minimum metadata retrieval cache period on remote repositories. If your deployment rejects `0`, use the lowest value it accepts. Requests that Artifactory answers from cache during that period are not attributed.
</Note>

### Create the worker

1. In JFrog, create two worker secrets that hold your Endor Labs credentials. Name them `ENDOR_API_KEY_ID` and `ENDOR_API_SECRET`.

2. Create a worker that uses the **Before Remote Info** event, which is the event that can add request headers before Artifactory contacts the remote source. For more information, refer to [Configure Workers for custom flows](https://docs.jfrog.com/administration/docs/configure-workers-for-custom-flows).

3. Attach the worker to each remote repository you route through Package Firewall.

4. Enter the following code as the worker body.

   ```typescript theme={null}
   // Workers has no base64 library, so encode the credentials here.
   function b64(value: string): string {
     const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
     const bytes = encodeURIComponent(value).replace(
       /%([0-9A-F]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))
     );
     let result = "";
     for (let i = 0; i < bytes.length; i += 3) {
       const n = (bytes.charCodeAt(i) << 16) |
                 (bytes.charCodeAt(i + 1) << 8) |
                 bytes.charCodeAt(i + 2);

       result += chars[(n >>> 18) & 63] + chars[(n >>> 12) & 63];
       result += i + 1 < bytes.length ? chars[(n >>> 6) & 63] : "=";
       result += i + 2 < bytes.length ? chars[n & 63] : "=";
     }
     return result;
   }

   export default async (
     context: PlatformContext,
     data: BeforeRemoteInfoRequest
   ): Promise<BeforeRemoteInfoResponse> => {
     const keyId = context.secrets.get("ENDOR_API_KEY_ID");
     const secret = context.secrets.get("ENDOR_API_SECRET");
     // The user identifier is a path that ends in the Artifactory username.
     const label = data.userContext?.id?.split("/").pop() ?? "";

     const requestHeaders: { [key: string]: Header } = {};

     // Without a label, send nothing and let the repository credentials authenticate.
     if (label && keyId && secret) {
       const username = b64(`${b64(`userattr:${label}`)}:${keyId}`);
       requestHeaders["Authorization"] = { value: [`Basic ${b64(`${username}:${secret}`)}`] };
     }

     return <any>{
       message: "proceed",
       status: ActionStatus.PROCEED,
       headers: requestHeaders,
     };
   };
   ```

5. Install a package through the repository, then confirm the attributed user on the event. See [View Package Firewall logs](/package-firewall/logs) to learn more.

The worker sends the Artifactory username as the label, which it takes from the last segment of the user identifier on the request. When developers sign in to Artifactory through single sign-on, the username is usually their email address. To report on something else, such as a team name, map the username to that value inside the worker before you encode it.

## Attribute requests from direct integrations

Direct integration writes the Package Firewall URL and credentials straight into each package manager configuration file, so you control the username on every client. Encode the label into the username using the format in [How the label reaches Package Firewall](#how-the-label-reaches-package-firewall), and write that value where the configuration file expects the user name.

The MDM scripts do exactly this, so use them as the reference implementation if you build your own deployment tooling. See [Configure the Package Firewall with direct integration](/package-firewall/direct-integration) to learn more.

## Choose a label

The following labels are examples of conventions you can adopt, not a list the platform defines. Package Firewall stores the label as one string and never parses it, so the convention you pick is the one your reporting has to live with. Use a single convention across the fleet. A mix of formats means every query against `spec.user` has to account for each one.

<YamlTable>
  {`
    - Label: \`doe@laptop-14\`
    Identifies: A person and the device they work on
    Use when: Each developer works on their own machine, which is what the MDM scripts assume by default
    - Label: \`doe@deer.ai\`
    Identifies: A person, independent of hardware
    Use when: You report by person, and developers change machines or use more than one
    - Label: \`doe@vm-build-07\`
    Identifies: A person on a shared host
    Use when: Several developers share one machine, so the host name alone identifies no one
    - Label: \`doe@AST-04412\`
    Identifies: A person and an inventory asset tag
    Use when: You reconcile firewall activity against a device inventory or an MDM asset record
    `}
</YamlTable>

Keep the half of the label that stays stable for whatever you report on. If you report by person, hold the person half steady even when hardware changes. If you report by device, hold the device half steady even when the user changes. On a shared virtual machine the device half stops identifying anyone, so the person half has to carry the attribution alone.

## Label constraints

Package Firewall treats the label as opaque. It stores and displays the value and never parses it, so no character in the label carries meaning to the platform. The `@` in the default label is a convention of the MDM scripts, not a separator the platform interprets.

* **Character set**: The label must be valid UTF-8. Package Firewall drops a label that is not valid UTF-8. The request still authenticates and the log still records the event, but with no attributed user.
* **Delimiters**: Every character is safe, including `:`, `@`, `/`, and spaces, and no delimiter is unsupported. The label is base64-encoded before it reaches the wire, so no character can break the format. A label such as `team:platform/doe@laptop-14` reaches the logs exactly as written.
* **Length**: Package Firewall imposes no maximum label length, and the Endor Labs service accepts labels of at least 16 KB. The practical ceiling comes from the transport on your side. HTTP proxies commonly cap a single header line at 8 KB. Also, pip, uv, and Go carry the encoded label inside the index URL of every request, so a long label inflates all their traffic. Encoding roughly doubles the label on the wire. The Basic-auth username is about 1.8 times the label length, and the `_auth` value npm derives from it is about 2.4 times. A label under 1 KB stays far below an 8 KB header line in every lane.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.