Skip to content

Fix IPM Python permissions issue on Windows - #1244

Open
isc-dchui wants to merge 3 commits into
mainfrom
fix-python-windows
Open

isc-dchui wants to merge 3 commits into
mainfrom
fix-python-windows

Conversation

@isc-dchui

Copy link
Copy Markdown
Collaborator

Description

Fixes #1137.

On Windows, the Python packages IPM installs under <install>\mgr\python can end up unreadable to
non-elevated processes. The ORAS code path is the only part of IPM that imports one of those packages,
so that is where it surfaces:

ERROR! ObjectScript error: <SYNTAX> *<class 'PermissionError'>: [Errno 13] Permission denied:
  'c:\intersystems\iris\mgr\python\oras\__init__.py' - Import

The permissions are not oras-specific. Every wheel IPM ships is affected, so application code
importing requests, urllib3, jsonschema or the rest from that directory fails the same way.

Cause, and why Windows

IPM installs its bundled wheels with pip install <wheel> -t <mgr>/python
(PythonWheel.cls), and a module's requirements.txt
the same way (Base.cls, InstallOrDownloadPythonRequirements).

pip's --target handling stages the install in a temporary directory and then moves the result into
the target. Three Windows behaviours combine from there:

  1. A move preserves the source security descriptor. Within a volume Windows renames rather than
    copies, so the staged tree's ACL travels into mgr\python unchanged: inherited entries become
    explicit and inheritance is switched off. A copy behaves the opposite way, letting the destination's
    permissions apply. Unix has no equivalent, since a file's mode is set by pip rather than derived
    from the directory it lands in.
  2. The staging directory is restrictive, because of the account. A JOB'd process on Windows runs under
    the IRIS service account rather than the launching user, and IPM's wheel install is JOB'd
    (InstallORASIfMissing uses job ..InitializeAndSignal() to pick
    up the new %IPM.Storage.Module definition). pip's gettempdir() therefore resolves to
    C:\WINDOWS\TEMP, which grants Users no read access at all. In a container every IRIS process
    runs as one account, which is why this never appears in CI.
  3. Elevation, not identity, decides access. The resulting DACL grants SYSTEM,
    BUILTIN\Administrators and OWNER RIGHTS full control and nothing else. A non-elevated token
    carries its Administrators SID as deny-only, so the same user on the same machine has access from
    an elevated shell and none otherwise.

The fix

Stage, then copy. New shared helper %IPM.Utils.Module.StagedPipInstall() points pip install -t at a
temporary directory and copies the result into mgr/python with %IPM.Utils.File.CopyDir(). Neither
copy path carries the source security descriptor (ROBOCOPY /E on Windows without /SEC,
%File.CopyDir on Unix), so every new file and directory inherits from mgr\python and the
destination's permissions become authoritative regardless of where pip staged or which account ran it.
No ACL calls and no platform branch. Both pip call sites go through it, and the staging directory is
removed on the failure path as well.

Replace same-named entries. Copying alone does not repair an instance that is already affected:
robocopy leaves an existing directory's security descriptor untouched, a file it overwrites in place
keeps its old ACL, and a file whose size and timestamp match the source is skipped entirely. So for
each top-level entry pip staged, the entry of the same name under mgr/python is deleted first. That
makes reinstalling IPM the repair, with no permission change and no icacls. The delete happens only
after pip succeeds, so a failed pip cannot leave mgr/python emptier than it started. A failed copy
still can, since the delete precedes it, so the copy reports that the package it was replacing may be
left partially installed.

bin, Scripts and __pycache__ are merged rather than replaced. pip's --target handling makes them
top-level entries by construction, so every wheel with a console script writes into the same bin, and
replacing it would delete another distribution's scripts. Neither needs repairing anyway.

A module's requirements.txt keeps the installed package instead, dropping the staged entry and warning.
That is what pip does without --upgrade, so the behaviour matches main; replacing would let a module's
pin change the requests or urllib3 that IPM's own ORAS client and everything else in the instance
gets. Which version should win in a shared mgr/python is in issue #1126.

Report it usefully. GetClient() mapped only
<OBJECT DISPATCH> to a real message and rethrew everything else, so the user saw a bare
PermissionError. It now recognizes a permission failure and reports "Reinstall IPM to repair the
installation", with the original error embedded so the failing path stays visible. The match is on the
exception details rather than e.Name, because the same underlying error has been seen as both
<SYNTAX> and <PYTHON EXCEPTION>, and it requires the details to name mgr/python so a permission
failure elsewhere on sys.path is not blamed on IPM. ORASInstalled()
used a bare except:, reporting a permission failure as "oras not installed" and sending IPM off to
re-run a wheel install that cannot succeed; it now catches only ImportError, and the caller warns
instead of retrying.

Reviewer notes

  • pDeleteFirst stays 0 on the CopyDir. Passing 1 would delete all of mgr/python, including the
    other ten wheels and IPM's own modules/python/ files.
  • Replacing entries has an accepted cost: when two wheels share a top-level directory name (a namespace
    package), installing the second removes the first's contribution. None of IPM's own wheels share a
    top-level name once the shared pip entries are excluded, but other modules' wheels use this resource
    processor too. Not a regression:
    pip install -t already refused to write into an existing target directory and installed only the
    .dist-info, losing the second wheel's files instead and claiming the package was installed.
  • A non-verbose requirements.txt install no longer prints pip's progress, since its output now goes to
    the same sink stream the wheel path uses. A failed install still prints it.
  • Two unrelated fixes ride along: the helper drops empty pip arguments, which a trailing or repeated space
    in a module's flags would otherwise produce, and a stray zwrite is gone from a test helper.
  • The repair needs write access to the broken directories. Wheels install during Initialize, which
    runs as whichever account performs the reinstall: the user's own account for a terminal
    IPM.Installer.setup(), or the IRIS service account for zpm install zpm and the autoinstall path.
    The bad DACL grants SYSTEM and Administrators, so a default LocalSystem service has the rights and
    the reported case is covered. A non-elevated user reinstalling from their own session is not, and is
    back to their machine administrator or icacls /inheritance:e /T.

Rejected alternatives

  • Fix the ACL after pip, with icacls /inheritance:e on what pip just created, or the same from the
    JOB'd process, which was verified to work with no elevation. Rejected: it widens permissions on a
    shared directory inside the IRIS install, unwanted at a site that hardened mgr\python deliberately.
    Staging and copying reaches the same end state without touching an ACL.
  • Control pip's temporary directory with TMP/TEMP for the child process, removing the bad source
    descriptor at its origin. Rejected: $zf(-100) has no environment option, so this needs os.environ
    mutation in the calling process or a wrapper script, and it covers only pip rather than anything else
    that writes into the staging area.
  • A readable sys.path shadow copy of the wheels somewhere the user can write. Verified to work,
    rejected: installing IPM's Python dependencies outside mgr\python is not acceptable, and
    sys.path[0] shadowing is process-global, so a pinned copy can silently downgrade the requests or
    urllib3 that unrelated application code gets.
  • Drop the JOB, which the wheel install needs anyway. Not sufficient either: it fixes the account and
    temporary directory for that one path while leaving every other write into mgr\python at the mercy
    of whichever account performed it.
  • Replace oras-py on the read path with %Net.HttpRequest against the OCI distribution API. Much
    larger, and it only hides this bug, since publish still imports oras and the other packages stay
    unreadable for everyone else.
  • Extract the wheels directly instead of shelling out to pip. Changes how every bundled wheel is
    installed, including RECORD/dist-info and entry points.

Testing

New integration test TestWheelInstallsIntoSharedTopLevelDirectory in
ProcessPythonWheel.cls, with two
fixture wheels sharing the ipm_shared_ns top-level directory. It asserts the second wheel's module
reaches mgr/python, which pip install -t alone would not have delivered. What becomes of the first
wheel's module in that directory is deliberately not asserted, since that is the part #1126 decides.

The requirements.txt path has no new test. lune-wheel-reqs-offline, lune-no-wheel-reqs-offline,
lune-wheel-reqs-online and package-with-python-deps install through it and assert the packages are
importable, which covers the staging and copy. The branch that keeps an already-installed package and
warns is left untested for the same reason: it holds main's behaviour until #1126 settles what it
should be.

Both branches of ResolveStagedEntries were exercised directly against a staging and target pair in the
container, as was the tightened PermissionError match.

Every claim above about move, copy and robocopy behaviour was established by a spike run directly with
icacls/robocopy on Windows, and through %IPM.Utils.File.CopyDir in the container for the Unix
branch. What pip install -t does with an entry the target already has, with and without --upgrade,
is from its _handle_target_dir and was reproduced with two versions of requests.

Manual verification on Windows: on an affected instance,
##class(%SYS.Python).Import("oras") failed with PermissionError from a non-elevated session before
the fix, and succeeds after reinstalling IPM from this branch.

Checklist

  • This branch has the latest changes from the main branch rebased or merged.
  • Changelog entry added.
  • Unit (zpm test -only) and integration tests (zpm verify -only) pass.
  • Style matches the style guide in the contributing guide.
  • Documentation has been/will be updated
    • Source controlled docs, e.g. README.md, should be included in this PR and Wiki changes should be made after this PR is merged (add an extra issue for this if needed)
  • Pull request correctly renders in the "Preview" tab.

@isc-kiyer isc-kiyer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@isc-dchui Looks good! Few minor comments. Question: does github actions support any windows runners so we can have some basic tests run on windows to catch such issues in future?

Comment thread src/cls/IPM/Repo/Oras/PackageService.cls Outdated
Comment thread src/cls/IPM/Utils/Module.cls Outdated
Comment thread src/cls/IPM/Utils/Module.cls Outdated
@isc-dchui

Copy link
Copy Markdown
Collaborator Author

@isc-kiyer Addressed the comments! As for Windows runners, my understanding is that yes they exist, but the problem is that there's no Windows IRIS container, so we'll need to install an IRIS kit onto the runner, set up a license key (secretly), and then install IPM. So not very straightforward unfortunately.

@isc-dchui
isc-dchui requested a review from isc-kiyer September 15, 2026 18:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ZPM find returns permission error

2 participants