Skip to content

Commit ef976a7

Browse files
Merge pull request #3061 from h2zh/drop-privs-cont
Get ready to enable DropPrivileges by default
2 parents d3d8cd3 + 4c571f4 commit ef976a7

16 files changed

Lines changed: 628 additions & 125 deletions

cache/advertise.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,10 @@ func getTickerRate(tok string) time.Duration {
281281
return validateTickerRate(tickerRate, tokenLifetime)
282282
}
283283

284-
func LaunchFedTokManager(ctx context.Context, egrp *errgroup.Group, cache server_structs.XRootDServer) {
284+
// LaunchFedTokManager starts the federation token refresh loop. When Server.DropPrivileges
285+
// is true, the token file is chown'ed to the xrootd user and group by xrdhttp-pelican plugin
286+
// (via xrootd.FileCopyToXrootdDir(false, 9, file)); pass nil to skip (e.g. in tests).
287+
func LaunchFedTokManager(ctx context.Context, egrp *errgroup.Group, cache server_structs.XRootDServer, copyToXrootdDir server_utils.FedTokCopyToXrootdFunc) {
285288
// Do our initial token fetch+set, then turn things over to the ticker
286289
tok, err := server_utils.CreateFedTok(ctx, cache)
287290
if err != nil {
@@ -294,8 +297,14 @@ func LaunchFedTokManager(ctx context.Context, egrp *errgroup.Group, cache server
294297
// gives us a bit of buffer in the event the Director is down for a short period of time.
295298
tickerRate := getTickerRate(tok)
296299

300+
// Set up the federation token directories in the cache
301+
err = server_utils.SetupFedTokDirs(cache)
302+
if err != nil {
303+
log.Errorf("Failed to setup federation token directory: %v", err)
304+
}
305+
297306
// Set the token in the cache
298-
err = server_utils.SetFedTok(ctx, cache, tok)
307+
err = server_utils.SetFedTok(ctx, cache, tok, copyToXrootdDir)
299308
if err != nil {
300309
log.Errorf("Failed to set the federation token: %v", err)
301310
}
@@ -326,7 +335,7 @@ func LaunchFedTokManager(ctx context.Context, egrp *errgroup.Group, cache server
326335
}
327336

328337
// Set the token in the cache
329-
err = server_utils.SetFedTok(ctx, cache, tok)
338+
err = server_utils.SetFedTok(ctx, cache, tok, copyToXrootdDir)
330339
if err != nil {
331340
log.Errorf("Failed to write the federation token: %v", err)
332341
}

config/config.go

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1341,8 +1341,8 @@ func SetServerDefaults(v *viper.Viper) error {
13411341

13421342
// Set fed token locations for cache/origin. Note that fed tokens aren't yet used by the
13431343
// Origin (2026-02-05), but they may be soon for things like third party copy.
1344-
v.SetDefault(param.Origin_FedTokenLocation.GetName(), filepath.Join(configDir, "origin-fed-token"))
1345-
v.SetDefault(param.Cache_FedTokenLocation.GetName(), filepath.Join(configDir, "cache-fed-token"))
1344+
v.SetDefault(param.Origin_FedTokenLocation.GetName(), filepath.Join(configDir, "fed-token", "origin-fed-token"))
1345+
v.SetDefault(param.Cache_FedTokenLocation.GetName(), filepath.Join(configDir, "fed-token", "cache-fed-token"))
13461346

13471347
runtimeDir, _, err := ensureRuntimeDir(v)
13481348
if err != nil {
@@ -1572,7 +1572,7 @@ func SetServerDefaults(v *viper.Viper) error {
15721572
// stash a copy of its value now.
15731573
v.SetDefault(param.Origin_TokenAudience.GetName(), v.GetString(param.Origin_Url.GetName()))
15741574

1575-
// Set defaults for Director, Registry, and Broker URLs only if the Discovery URL is not set.
1575+
// Set defaults for Director and Registry URLs only if the Discovery URL is not set.
15761576
// This is necessary because, in Viper, there is currently no way to check if a value is coming
15771577
// from the default or was explicitly set by the user. Therefore, if the DiscoveryURL is present,
15781578
// when populating the Director, Registry, and Broker URLs, the discoverFederationImpl function
@@ -1586,7 +1586,6 @@ func SetServerDefaults(v *viper.Viper) error {
15861586
// https://github.com/spf13/viper/issues/1814
15871587
if !v.IsSet(param.Federation_DiscoveryUrl.GetName()) {
15881588
v.SetDefault("Federation.RegistryUrl", v.GetString(param.Server_ExternalWebUrl.GetName()))
1589-
v.SetDefault("Federation.BrokerURL", v.GetString(param.Server_ExternalWebUrl.GetName()))
15901589
v.SetDefault("Federation_DirectorUrl", v.GetString(param.Server_ExternalWebUrl.GetName()))
15911590
}
15921591

@@ -1673,26 +1672,19 @@ func InitServer(ctx context.Context, currentServers server_structs.ServerType) e
16731672
// Set up the directories for the server to run as a non-root user;
16741673
// for the most part, we need to recursively chown and chmod the directory
16751674
// so either root or pelican can access it.
1676-
pelicanLocations := []string{
1675+
pelicanLocationsNoRecursive := []string{
16771676
param.Server_DbLocation.GetString(),
16781677
}
1679-
if currentServers.IsEnabled(server_structs.RegistryType) {
1680-
pelicanLocations = append(pelicanLocations, param.Registry_DbLocation.GetString())
1681-
}
1682-
if currentServers.IsEnabled(server_structs.OriginType) {
1683-
pelicanLocations = append(pelicanLocations, param.Origin_DbLocation.GetString())
1684-
}
1685-
if currentServers.IsEnabled(server_structs.DirectorType) {
1686-
pelicanLocations = append(pelicanLocations, param.Director_DbLocation.GetString(), param.Director_GeoIPLocation.GetString())
1687-
}
1688-
if err = setFileAndDirPerms(pelicanLocations, 0750, 0640, puser.Uid, 0, true); err != nil {
1689-
return errors.Wrap(err, "failure when setting up the file permissions for pelican")
1690-
}
1691-
1692-
pelicanLocationsNoRecursive := []string{}
16931678
if (currentServers.IsEnabled(server_structs.OriginType) || currentServers.IsEnabled(server_structs.CacheType)) && param.Shoveler_Enable.GetBool() {
16941679
pelicanLocationsNoRecursive = append(pelicanLocationsNoRecursive, param.Shoveler_AMQPTokenLocation.GetString())
16951680
}
1681+
if currentServers.IsEnabled(server_structs.CacheType) {
1682+
tokLoc := param.Cache_FedTokenLocation.GetString()
1683+
tokDir := filepath.Dir(tokLoc)
1684+
dir := filepath.Dir(tokDir)
1685+
tempTokDir := filepath.Join(dir, "fed-token-temp")
1686+
pelicanLocationsNoRecursive = append(pelicanLocationsNoRecursive, tempTokDir)
1687+
}
16961688
if err = setFileAndDirPerms(pelicanLocationsNoRecursive, 0750, 0640, puser.Uid, 0, false); err != nil {
16971689
return errors.Wrap(err, "failure when setting up the file permissions for pelican")
16981690
}
@@ -1718,9 +1710,9 @@ func InitServer(ctx context.Context, currentServers server_structs.ServerType) e
17181710
if (currentServers.IsEnabled(server_structs.OriginType) || currentServers.IsEnabled(server_structs.CacheType)) && param.Shoveler_Enable.GetBool() {
17191711
pelicanDirs = append(pelicanDirs, param.Shoveler_QueueDirectory.GetString())
17201712
}
1721-
if currentServers.IsEnabled(server_structs.OriginType) {
1722-
pelicanDirs = append(pelicanDirs, param.Origin_GlobusConfigLocation.GetString())
1723-
}
1713+
// Note: Origin_GlobusConfigLocation is intentionally NOT added here.
1714+
// It's under Origin_RunLocation (e.g. /run/pelican/xrootd/origin/) which should be owned by xrootd, not pelican.
1715+
// InitGlobusBackend() handles creating and chowning the Globus directories properly.
17241716
if err = setDirPerms(pelicanDirs, 0750, 0640, puser.Uid, puser.Gid, true); err != nil {
17251717
return errors.Wrap(err, "failure when setting up the directory permissions for pelican")
17261718
}
@@ -1988,6 +1980,14 @@ func InitServer(ctx context.Context, currentServers server_structs.ServerType) e
19881980
return err
19891981
}
19901982

1983+
// When drop privileges is enabled, ensure the pelican user can read TLS credentials.
1984+
// XRootD does not need direct access to these files as Pelican copies them to a runtime location.
1985+
if param.Server_DropPrivileges.GetBool() {
1986+
if err = CheckTLSCredsForDropPrivileges(); err != nil {
1987+
return err
1988+
}
1989+
}
1990+
19911991
// The certificate was either generated or has been provided by now. Verify that any configured
19921992
// hostnames are valid w.r.t the given certificate.
19931993
//

config/init_server_creds.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"crypto/x509"
2828
"crypto/x509/pkix"
2929
"encoding/pem"
30+
goerrors "errors"
3031
"fmt"
3132
"io/fs"
3233
"math/big"
@@ -121,6 +122,51 @@ func createDirForKeys(dir string) error {
121122
return nil
122123
}
123124

125+
// CheckTLSCredsForDropPrivileges ensures proper permissions on TLS key and certificate files
126+
// when Server.DropPrivileges is enabled, so the pelican user can read them.
127+
func CheckTLSCredsForDropPrivileges() error {
128+
if !param.Server_DropPrivileges.GetBool() {
129+
return nil
130+
}
131+
132+
puser, err := GetPelicanUser()
133+
if err != nil {
134+
return errors.Wrap(err, "failed to get pelican user for TLS credentials check")
135+
}
136+
137+
tlsCert := param.Server_TLSCertificateChain.GetString()
138+
tlsKey := param.Server_TLSKey.GetString()
139+
140+
// Check both TLS certificate and key files; collect all errors so we report
141+
// every file with permission or existence problems instead of exiting on the first.
142+
var errs []error
143+
filesToCheck := []string{tlsCert, tlsKey}
144+
for _, filePath := range filesToCheck {
145+
if filePath == "" {
146+
continue
147+
}
148+
149+
fileInfo, err := os.Stat(filePath)
150+
if err != nil {
151+
if os.IsNotExist(err) {
152+
// File doesn't exist - this is an error at this point since GenerateCert
153+
// should have been called first
154+
return errors.Errorf("file %s does not exist when checking TLS credentials for drop privileges", filePath)
155+
}
156+
return errors.Wrapf(err, "failed to stat TLS file %s", filePath)
157+
}
158+
159+
// Check file readability using platform-specific logic
160+
if err := checkFileReadableByUser(filePath, fileInfo, puser); err != nil {
161+
errs = append(errs, err)
162+
}
163+
}
164+
if len(errs) > 0 {
165+
return goerrors.Join(errs...)
166+
}
167+
return nil
168+
}
169+
124170
// Return a pointer to an ECDSA private key or RSA private key read from keyLocation.
125171
//
126172
// This can be used to load ECDSA or RSA private key for various purposes,

config/init_server_creds_unix.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
//go:build !windows
2+
3+
/***************************************************************
4+
*
5+
* Copyright (C) 2026, Pelican Project, Morgridge Institute for Research
6+
*
7+
* Licensed under the Apache License, Version 2.0 (the "License"); you
8+
* may not use this file except in compliance with the License. You may
9+
* obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing, software
14+
* distributed under the License is distributed on an "AS IS" BASIS,
15+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
* See the License for the specific language governing permissions and
17+
* limitations under the License.
18+
*
19+
***************************************************************/
20+
21+
package config
22+
23+
import (
24+
"fmt"
25+
"os"
26+
"os/user"
27+
"syscall"
28+
29+
"github.com/pkg/errors"
30+
log "github.com/sirupsen/logrus"
31+
)
32+
33+
// checkFileReadableByUser checks if the given user can read the file on Unix systems.
34+
// It examines the file's uid/gid/mode to determine readability.
35+
//
36+
// Note: This is a best-effort check performed at startup. It is subject to a TOCTOU
37+
// (time-of-check-time-of-use) race: file permissions could change between this check and
38+
// when the file is actually read. This is acceptable because the check is intended as an
39+
// early diagnostic to catch common misconfiguration, not as a security gate.
40+
func checkFileReadableByUser(filePath string, fileInfo os.FileInfo, puser User) error {
41+
stat, ok := fileInfo.Sys().(*syscall.Stat_t)
42+
if !ok {
43+
// Fallback for any platform where Stat_t isn't available
44+
log.Warnf("Cannot verify ownership of %s on this platform; ensure the pelican user (%s) can read it", filePath, puser.Username)
45+
return nil
46+
}
47+
48+
fileUid := int(stat.Uid)
49+
fileGid := int(stat.Gid)
50+
fileMode := fileInfo.Mode().Perm()
51+
52+
// Check if the pelican user can read the file:
53+
// 1. If pelican user owns the file and has read permission (owner read bit)
54+
// 2. If pelican user's primary or supplementary group matches file's group and group has read permission
55+
// 3. If others have read permission
56+
canRead := false
57+
58+
if fileUid == puser.Uid {
59+
// Pelican user owns the file, check owner read permission
60+
if fileMode&0400 != 0 {
61+
canRead = true
62+
}
63+
} else if fileMode&0040 != 0 && userInGroup(puser, fileGid) {
64+
// File has group read permission and the pelican user is in the file's group
65+
// (either via primary GID or supplementary groups)
66+
canRead = true
67+
} else {
68+
// Check others read permission
69+
if fileMode&0004 != 0 {
70+
canRead = true
71+
}
72+
}
73+
74+
if !canRead {
75+
return errors.Errorf("TLS file %s is not readable by the pelican user (%s, uid=%d, gid=%d). "+
76+
"File has owner uid=%d, gid=%d, mode=%04o. "+
77+
"Please ensure the pelican user can read this file when Server.DropPrivileges is enabled",
78+
filePath, puser.Username, puser.Uid, puser.Gid, fileUid, fileGid, fileMode)
79+
}
80+
81+
return nil
82+
}
83+
84+
// userInGroup returns true if the user's primary GID matches fileGid, or if
85+
// fileGid appears in the user's supplementary group list.
86+
func userInGroup(puser User, fileGid int) bool {
87+
if fileGid == puser.Gid {
88+
return true
89+
}
90+
91+
// Look up supplementary groups for the user
92+
u, err := user.Lookup(puser.Username)
93+
if err != nil {
94+
log.Debugf("Could not look up supplementary groups for user %s: %v", puser.Username, err)
95+
return false
96+
}
97+
groupIds, err := u.GroupIds()
98+
if err != nil {
99+
log.Debugf("Could not retrieve supplementary groups for user %s: %v", puser.Username, err)
100+
return false
101+
}
102+
103+
fileGidStr := fmt.Sprintf("%d", fileGid)
104+
for _, gidStr := range groupIds {
105+
if gidStr == fileGidStr {
106+
return true
107+
}
108+
}
109+
110+
return false
111+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
//go:build windows
2+
3+
/***************************************************************
4+
*
5+
* Copyright (C) 2026, Pelican Project, Morgridge Institute for Research
6+
*
7+
* Licensed under the Apache License, Version 2.0 (the "License"); you
8+
* may not use this file except in compliance with the License. You may
9+
* obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing, software
14+
* distributed under the License is distributed on an "AS IS" BASIS,
15+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
* See the License for the specific language governing permissions and
17+
* limitations under the License.
18+
*
19+
***************************************************************/
20+
21+
package config
22+
23+
import (
24+
"os"
25+
26+
log "github.com/sirupsen/logrus"
27+
)
28+
29+
// checkFileReadableByUser on Windows logs a warning since Unix-style permission
30+
// checking doesn't apply. Windows uses ACLs which have a different permission model.
31+
// The Server.DropPrivileges feature is primarily designed for Unix systems.
32+
func checkFileReadableByUser(filePath string, fileInfo os.FileInfo, puser User) error {
33+
log.Warnf("Cannot verify ownership of %s on Windows; ensure the pelican user (%s) can read it", filePath, puser.Username)
34+
return nil
35+
}

e2e_fed_tests/cache_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ func TestCacheFedTokMaint(t *testing.T) {
6565

6666
// Give this "cache" instance a unique location so it doesn't compete with the fed test cache token
6767
require.NoError(t, param.Set(param.Cache_FedTokenLocation.GetName(), filepath.Join(t.TempDir(), t.Name()+"_fedtok")))
68-
cache.LaunchFedTokManager(ctx, egrp, &cacheServer)
68+
cache.LaunchFedTokManager(ctx, egrp, &cacheServer, nil)
6969
tokFile := cacheServer.GetFedTokLocation()
7070

7171
ticker := time.NewTicker(1 * time.Second)

launchers/cache_serve.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
_ "embed"
2626
"net"
2727
"net/url"
28+
"os"
2829
"strconv"
2930
"time"
3031

@@ -112,7 +113,11 @@ func CacheServe(ctx context.Context, engine *gin.Engine, egrp *errgroup.Group, m
112113
// Director tests or federation tokens.
113114
if !param.Cache_EnableSiteLocalMode.GetBool() {
114115
cache.LaunchDirectorTestFileCleanup(ctx)
115-
cache.LaunchFedTokManager(ctx, egrp, cacheServer)
116+
cache.LaunchFedTokManager(ctx, egrp, cacheServer, func(f *os.File) error {
117+
// In drop-privileges mode, the token file is chown'ed to the xrootd user
118+
// and group by xrdhttp-pelican plugin by passing command "9" to the plugin.
119+
return xrootd.FileCopyToXrootdDir(false, 9, f)
120+
})
116121
}
117122

118123
concLimit := param.Cache_Concurrency.GetInt()

0 commit comments

Comments
 (0)