Restricting Access with IP Embargo

Archipelago can restrict an ADO’s media to visitors on a particular network range while leaving its metadata public to everyone. We use this for objects whose media lives in Avalon and is licensed for on-campus viewing only.

This uses Archipelago’s built-in metadata-driven embargo with the IP bypass option. No custom module is needed. See also the upstream Archipelago embargo documentation.

Note

The behavior described here was verified against the format_strawberryfield module source (src/EmbargoResolver.php), which differs from the 1.6.0 documentation in a few places. Where the two disagree, this page follows the source.

Warning

Embargo is not an access control list. An embargoed ADO is still a published node. Its page loads for everyone, it still appears in search and browse, and its metadata exports stay open. Only viewers and files are withheld. Read What IP Embargo Does Not Cover before promising a depositor that something is restricted.

How the Embargo Flag Works

Embargo settings name a single flat, top-level key in the ADO’s JSON. It is a plain key name, not a JMESPath expression, so it cannot point at a nested value.

The value stored under that key decides what happens, and its JSON type matters as much as its content:

Value under the key

Effect

Key is absent

No embargo. The object is public.

null, false, 0, "", or a blank cell

No embargo. The object is public.

true (boolean) or 1 (integer)

Embargoed. The visitor’s IP is checked against the site-wide bypass list. This is the shape TAMU uses.

An array of ranges, e.g. ["128.194.0.0/16"]

Embargoed. The visitor’s IP is checked against that per-object list.

Any string, including "1", "T", or a URL

Embargoed for everyone, permanently, on campus included. Never do this.

Why Strings Break Everything

A string value is passed straight to the IP matcher:

elseif (is_string($jsondata[$ip_embargo_key])) {
  $ip_embargo = IpUtils::checkIp4($current_ip, trim($jsondata[$ip_embargo_key]));
  $noembargo = $noembargo && $ip_embargo;   // -> FALSE
}

checkIp4() cannot parse "T", "1", or a URL, so it returns false and the object is embargoed. Nothing downstream can undo that, including a request from a campus address. This is why the flag must be written as a bare 1 and never as a quoted string.

Why We Use a Boolean or Integer Flag

A true or 1 value skips the string branch entirely and is evaluated against the site-wide list of campus ranges:

if ($this->embargoConfig->get('global_ip_bypass_enabled')) {
  $global_ip_embargo = ((is_bool($v) && $v == TRUE) || $v == "1" || $v == 1);
  if ($global_ip_embargo) {
    $ip_embargo = $this->evaluateGlobalIPembargo($current_ip);
    $noembargo = $noembargo && $ip_embargo;
  }

Campus ranges then live in one configuration field instead of being copied into every object, which is what we want for a list that changes over time.

Two consequences of taking this branch:

  • Global IP Range Bypass Mode does not apply. The three-option dropdown (replace / additive / local) is only reached when the ADO itself held real IP values. With a boolean or integer flag the mode is never read, so its value does not matter.

  • The modes could only ever tighten access anyway. By the time they run, the local list has already been combined with &&, so an “additive” list cannot widen access.

Flagging an Object During Ingest

The CSV Column

Add an embargo_ip_bypass column to the AMI spreadsheet:

  • 1 means campus only

  • 0 or blank means public

The AMI Ingest Template

In twig/metadatadisplays/AMI_Ingest_JSON_Template.twig.html, the key is emitted after related_url:

{# bare 1/null - do NOT use |json_encode|raw here like the keys around it. A quoted string is
   passed to IpUtils::checkIp4(), fails to parse, and embargoes the object for everyone including
   on campus. CSV column: 1 = campus only, 0 or blank = public. #}
"embargo_ip_bypass": {{ data.embargo_ip_bypass|default('')|trim == '1' ? '1' : 'null' }},

Warning

This line deliberately breaks the |json_encode|raw pattern used by every other key in that template. “Fixing” it to match its neighbors produces a quoted string, which locks every flagged object permanently. The comment above the line is there to stop exactly that.

When the Template Does Not Run

Some AMI sets are configured for direct column-to-key mapping. In those sets the ADO JSON keys are the CSV column headers themselves, the Twig ingest template never runs, and raw cell values pass through untouched. A T in the spreadsheet becomes the JSON string "T", which permanently embargoes the object.

Before relying on the template, confirm which kind of set you are working with, and check the resulting JSON of at least one object. AMI does coerce some types on the direct path (a date_issued of 1953 arrives as a JSON integer), which is another reason 1 rather than T or true is the safest cell value.

Configuring Embargo in Drupal

The settings live at /admin/config/archipelago/metadatabased_Embargo. The form turns embargo enforcement on at the formatter level and names the JSON keys it acts on:

The Archipelago metadata based embargo settings form, showing the two JSON key fields, the Embargo Direct File Paths and Enable Global IP Range Bypass checkboxes, the bypass mode dropdown, and the global IP ranges box

Reading the form from top to bottom, these are the values the test site currently holds:

Field

Value

Why

Is Embargo checking and enforcing globally active?

Checked

The master switch. Nothing below it is consulted while it is off.

JSON key that contains an embargo lift date

date_embargo_lift

Named on the form, but date embargo is not part of this work. The key is inert unless an ADO actually carries it, in which case that object would be embargoed until the date passes.

JSON key that contains an allowed to bypass embargo through a visitor IP or IP range

embargo_ip_bypass

The key this whole feature turns on. It must match the key in the ingest template character for character.

Embargo Direct File Paths

Checked

The setting that actually refuses a download or stream to someone who knows the direct file path. The form notes it has performance implications for streamed media, and that hiding file-based viewers remains the site administrator’s job.

Enable Global IP Range Bypass Settings

Checked

Without it, a boolean or integer flag is evaluated against nothing and the object stays public.

Global IP Range Bypass Mode

Global IP Range will override any granular ADO values

A required field, but not read for boolean or integer flags. It only changes behavior for ADOs that carry IP values of their own.

Global IP addresses and ranges embargo bypass

Three ranges, described below

The allow list.

Warning

Note the key naming in the screenshot. The help text under Enable Global IP Range Bypass Settings gives its example as { "ip_embargo_bypass": true }, the field above it is set to embargo_ip_bypass, and the module’s own config schema defaults to ip_embargoed. That is three different names for one key. Only the value typed into the field matters, and an ADO whose JSON uses any other spelling is simply public. See Ways Embargo Silently Does Nothing.

The Campus IP Ranges

Global IP addresses and ranges embargo bypass takes one CIDR range per line. The test site currently holds:

10.0.0.0/8
128.194.0.0/16
172.31.0.0/16

Range

What it is

10.0.0.0/8

RFC 1918 private space. On our Kubernetes deployment this covers the cluster’s own pod and ingress addresses, including the 10.244.217.104 that dblog reports as the visitor Hostname. A testing entry, not a campus range.

128.194.0.0/16

The public Texas A&M campus range. This is the entry that does the intended work.

172.31.0.0/16

RFC 1918 private space used for internal and VPN-style addressing. Also a testing entry rather than a public campus range.

Warning

This list must not go to production as it stands. Two of its three ranges are private space, and while the reverse proxy is still unconfigured, Drupal compares each request against the ingress address rather than the visitor’s. That address falls inside 10.0.0.0/8, so every request matches the bypass list and no object is restricted from anyone. The form reads as correctly configured while enforcing nothing.

A production list should hold only public campus ranges, confirmed with network services, and should be set only once the reverse proxy work in Prerequisites for Production is done. Until then, treat the ranges above as scaffolding for testing.

At /admin/config/archipelago/metadataexpose, tick Return 401 in case of an Embargo on iiifmanifest, iiifmanifestv2, iiifmanifest3cws, and iiifmanifest3collection. Leave mods, dc, and geojson open so metadata keeps exporting.

On the display mode formatters, tick Hide the Viewer in the presence of an Embargo. There is also an embargo_json_key_source setting, which swaps in an alternate file set when an object is embargoed (a placeholder image, for example) instead of hiding the viewer altogether.

Showing a Campus-Only Notice

Object_Description.twig.html and Object Display Clover.twig.html display a campus-only notice gated on data_embargo.embargoed. In Object_Description, the notice sits above the tab content, between </nav> and <div class="tab-content">, so it appears whichever tab is active.

Both templates already carry this flag, which hides the downloads dropdown:

{% set file_download_restricted = false %}
{% if data_embargo.embargoed == true %}
   {% set file_download_restricted = true %}
{% endif %}

Note

That flag only hides links. It does not block the URLs behind them. Refusing the download itself is the job of Embargo Direct File Paths in the Drupal settings above.

Roles That Bypass Embargo

EmbargoResolver.php short-circuits before any IP logic for:

  • the administrator role, which is never embargoed under any circumstances

  • see strawberryfield embargoed ados

  • see strawberryfield time embargoed ados

  • see strawberryfield IP embargoed ados

Warning

Always test logged out or in a private window. Testing as an administrator shows content unrestricted regardless of your IP, which looks identical to a broken embargo.

What IP Embargo Covers

The embargo resolver is consulted by:

  • IiifBinaryController, for direct file downloads, but only when Embargo Direct File Paths is on

  • MetadataExposeDisplayController, for the /do/… endpoints, but only where Return 401 in case of an Embargo is ticked for that expose config

  • MetadataAPIController and MetadataDisplaySearchController

  • the field formatters for Video, Audio, Image, Media, PDF, Paged, Mirador, Universal Viewer, 3D, Map, WARC, Pannellum, Citation, and MetadataTwig

  • the ADOAccess Views argument validator, which is opt-in

What IP Embargo Does Not Cover

Gap

Detail

Search index

The module feeding Solr has no embargo awareness. StrawberryFlavorContentAccess uses Drupal node access grants, not embargo. Embargoed objects are still published nodes, so they appear in search and browse, and extracted text is indexed regardless.

IIIF Content Search

IiifContentSearchController injects the embargo resolver but never calls it.

Cantaloupe

If the IIIF image server sits on a public hostname, tiles can be fetched directly from it.

Metadata exports

mods.xml, dc.xml, GeoJSON, and OAI-PMH stay open by design. Anything they contain, such as an Avalon URL in related_url, is published to harvesters.

Node access

Not ACL. The object page still loads for everyone.

IPv6

The matcher is IpUtils::checkIp4(). A campus visitor arriving over IPv6 is treated as off campus.

Reverse proxy

getClientIp() returns the proxy’s address unless Drupal’s reverse_proxy settings are configured.

Page cache

Disabled for anonymous visitors on any object carrying the embargo key.

Note

The search gap and the ingest decision compound each other. If an ADO carries a text derivative, such as an HTML capture of the Avalon page it was built from, that text is indexed and searchable even while the object is embargoed. Where the media itself is restricted, the safer choice is not to ingest derivatives of it at all rather than to ingest them and rely on embargo.

Ways Embargo Silently Does Nothing

Three misconfigurations fail open, with no error and no log entry, so the site looks exactly like a correctly configured one that happens to have nothing restricted:

  1. The key name does not match. The upstream example uses embargo_ip_bypass while the module’s config schema defaults to ip_embargoed. If the settings string and the JSON key differ by a single character, every object is public.

  2. Enable Global IP Range Bypass Settings is unticked. A boolean true then matches neither the array branch nor the string branch, nothing is evaluated, and the object is public.

  3. The value is falsy. false, 0, "", or a blank cell all read as “no embargo”.

One misconfiguration fails closed: any non-IP string locks the object to everyone, as described in How the Embargo Flag Works.

Testing an Embargoed Object

  1. Log out, or use a private window. An administrator bypasses everything.

  2. Confirm the ADO JSON contains "embargo_ip_bypass": 1, unquoted.

  3. Set the global bypass addresses to 192.0.2.0/24, a range that matches nobody. Expect the campus-only notice, a hidden viewer, and a 401 from /do/{uuid}/metadata/iiifmanifest/default.jsonld.

  4. Replace it with the address Drupal actually sees for your client, clear caches, and confirm everything renders normally.

  5. Confirm that a control object without the flag is unaffected in both states.

  6. Confirm that a direct /do/{node}/file/{uuid}/download/{name} URL returns 403 while embargoed. This requires Embargo Direct File Paths.

Note

Metadata stays visible in every restricted case. That is by design, not a failed test. The 401 on the manifest is the cleanest pass or fail signal.

Troubleshooting

  • Nothing appears restricted. You are probably logged in as an administrator. Retest in a private window before changing any configuration.

  • An object is restricted even on campus. Check the ADO JSON for a quoted value. A string of any kind embargoes the object for everyone.

  • Do not draw conclusions while 10.0.0.0/8 is in the bypass list. Campus WiFi NAT and container networks live in that range, so it matches the very addresses you are trying to test against, including the cluster’s own ingress. Remove it, or use 192.0.2.0/24, the RFC 5737 documentation range, when you want a list that matches nobody. See The Campus IP Ranges.

  • Find the IP Drupal is comparing against. Go to /admin/reports/dblog, open any entry, and read Hostname. On our test site this showed 10.244.217.104, a Kubernetes pod address, meaning every request looked identical because Drupal was seeing the ingress rather than the visitor.

  • Never put the proxy address in the bypass list. Since every request appears to come from it, doing so grants campus access to the entire internet while looking like the feature works.

  • An object display template will not render on a test site. Check for Twig extensions the test site does not have. A missing function, such as allmaps_annotation_url, is a compile-time error that fails the whole template and cannot be guarded with {% if %}; set the value to null for testing instead. A missing attach_library() target, such as strawberryfield_clover, is only a warning: the page renders and the Clover JS does not initialize, which is harmless for embargo testing.

Prerequisites for Production

These items are still open, and IP embargo cannot be trusted in production until they are resolved:

  1. Reverse proxy. $settings['reverse_proxy'] and $settings['reverse_proxy_addresses'] must be set in settings.php, and the ingress must forward X-Forwarded-For. Until the dblog Hostname shows real client addresses, no IP rule can work.

  2. The campus CIDR list. Available from network services, or from the EZproxy and e-resources configuration, which usually maintains one already for vendor licensing. Decide explicitly whether VPN, branch campuses, and residence halls count as on campus.

  3. IPv6. Confirm that campus clients present IPv4 addresses. If they do not, this feature cannot work as designed.

  4. How Avalon itself restricts the item. If Avalon’s restriction is login-based rather than IP-based, mirroring it as an IP rule is wrong in both directions.

  5. Whether restricted items should be ingested at all. Not ingesting a poster image and page capture is simpler and safer than ingesting them and then relying on embargo.

Checking Avalon’s Access State

Avalon exposes an item’s access state in two ways:

  1. The HTTP status of its manifest. GET /media_objects/<id>/manifest.json returns 401 when restricted and 200 when public. This is only meaningful when requested from outside the allowed audience.

  2. The Access Restrictions entry inside the manifest metadata, for example "This item is accessible by: the public." This is authoritative, but only readable once you already have access.

A reliable rule is that 401 means restricted, and for 200 you read the Access Restrictions value. Verified samples: 4b29b606w returns 200 and is public, while 41687h652 returns 401 and is restricted.

Warning

Twig cannot perform this check. No registered Archipelago Twig extension makes HTTP requests, so any manifest-based determination has to happen before ingest, while the spreadsheet is being prepared.