We run a multi-tenant Orthanc as a public DIMSE ingress (1.12.11, Python plugin 7.1). Each tenant is identified by a Called AE Title we issue, because our senders’ Calling AEs are vendor factory defaults (OEM_StoreSCU, STORESCU, VXVUE) that are neither unique across clinics nor freely changeable on the device.
Today our filter accepts everything and we authorize at STABLE_STUDY by reading CalledAET from instance metadata, per Python Auto-route and called AET . That works, but it means unauthenticated bytes hit storage before any authorization decision, and the sender always sees C-STORE success, so a clinic that typos its Called AE gets a green light and no error. We end up delivering that rejection by email and phone instead of in-protocol.
The filter can already reject (it receives uint16_t* dimseStatus, so we would return 0x0124), it
just cannot see the field the decision depends on. Orthanc does hand the Called AET to plugins
elsewhere: OnFind(answers, query, issuerAet, calledAet) and the move callback both get it. Only
the incoming C-STORE path lacks it, at both levels: no called-AET parameter on OrthancPluginIncomingCStoreInstanceFilter, and no counterpart to OrthancPluginGetInstanceRemoteAet in the DicomInstance group. Verified on Python plugin 7.1: none of the 15 methods on the DicomInstance handed to the filter yields it, though it appears in instance metadata immediately after storage.
Smallest thing that would fix it: add OrthancPluginGetInstanceCalledAet(context, instance), mirroring OrthancPluginGetInstanceRemoteAet, returning the Called AET when the origin is DicomProtocol and an empty string otherwise. No callback signature changes, and the Python plugin would surface it as DicomInstance.GetInstanceCalledAet(). A filter variant carrying const char* calledAet would also work, but the accessor additionally covers RegisterIncomingDicomInstanceFilter and the stored-instance callback.
I did not find this in the TODO or on the forum, apologies if I missed it. Happy to test a patch
against a staging gateway that takes real traffic from a range of vendor consoles.
I was trying to understand the implications of this as a way into the current Orthanc codebase, and noticed that there is an example of adding a C-STORE filter in the python module documentation
Could you apply the security filter at that point, and then do the routing in STABLE_STUDY?
Thanks for digging into it. That is the hook we already use, and the routing does happen in
STABLE_STUDY. The catch is the discriminator: the example filters on GetInstanceRemoteAet(), the Calling AE, and across our senders that is a shared vendor factory default (STORESCU, OEM_StoreSCU, VXVUE). It tells us the make of the console, not which tenant is sending.
I re-probed today to be sure I was not just missing an accessor. Orthanc 1.12.11, Python plugin
7.1, one C-STORE with Calling AE SOUNDDR01 and Called AE CRX_FAKECRED99. Inside the filter:
GetInstanceRemoteAet() = 'SOUNDDR01'
HasInstanceMetadata('CalledAET') = 0
GetInstanceMetadata('CalledAET') = None
The same instance in RegisterOnStoredInstanceCallback:
{"CalledAET": "CRX_FAKECRED99", "RemoteIP": "...", "Origin": "DicomProtocol", ...}
So Orthanc has the value, it is just not reachable until after the instance is stored. RemoteIP is
in the same boat, so a source-IP check at filter time is out as well, and none of the 15 methods on
the DicomInstance yields the Called AE. (multitenant-dicom does not help either: it is an extra
DICOM server and TCP port per tenant.)
If our modalities had distinct Calling AEs, the documented hook would be all we need. The ask is narrowly that the field which does identify our tenants be readable where the example already reads the Calling AE.
Hi,
I have implemented this change both in Orthanc and in the python plugin.
This should be available in the mainline binaries tomorrow if the build succeeds.
I have tested it with this python script:
import orthanc
def FilterIncomingCStoreInstance(receivedDicom):
origin = receivedDicom.GetInstanceOrigin()
if origin == orthanc.InstanceOrigin.DICOM_PROTOCOL: # should always be true in the CStore callback !
orthanc.LogWarning(f"--------- RemoteAET: {receivedDicom.GetInstanceRemoteAet()}, CalledAET: {receivedDicom.GetInstanceCalledAet()}, RemoteIP: {receivedDicom.GetInstanceRemoteIp()}")
return 0 # Success: Accept the DICOM instance
orthanc.RegisterIncomingCStoreInstanceFilter(FilterIncomingCStoreInstance)
Hope this helps …
If you are using Orthanc in the scope of a commercial application, don’t forget to support the projet; we are running short of donations.
Alain
Thank you! That was fast, and GetInstanceCalledAet() is exactly the shape I was hoping for. GetInstanceRemoteIp() closes a second gap I had measure but not raised: RemoteIP was also only readable after storage, so both land in one go.
Noted on the Python plugin needing SDK 1.13.1. I will run both against the mainline binaries once they build and report back here.