Skip to content

Commit 5530713

Browse files
Backport: ACLs: add fine-grained ACLs for Sentinel CRUD operations (#27556) (#27557)
Updating Sentinel policies currently requires a management token. We'd like to break up the management token capabilities so that users can provide less-privileged tokens to services for specific purposes. For example, one might want to run testing on Sentinel policies and not allow that test pipeline to have full management access. Add support for fine-grained ACLs on Sentinel CRUD operations. Note that unlike most ACLs, Sentinel is disabled when ACLs are disabled. This is the CE portion of the work. The Sentinel RPC handlers are in the Enterprise PR. Ref: hashicorp/nomad-enterprise#3700 Ref: https://hashicorp.atlassian.net/browse/NMD-512 Fixes: #24225 Co-authored-by: Tim Gross <tgross@hashicorp.com>
1 parent b639b7f commit 5530713

9 files changed

Lines changed: 259 additions & 4 deletions

File tree

.changelog/27556.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
```release-note:improvement
2+
acl (Enterprise): Added `sentinel` policy block to allow managing Sentinel policies without a management token
3+
```

acl/acl.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,13 @@ type ACL struct {
7373
agent string
7474
node string
7575
operator string
76+
sentinel string
7677
quota string
7778
plugin string
7879

7980
// Fine-grained capabilities for policies that don't need namespace globbing
8081
operatorCapabilities capabilitySet
82+
sentinelCapabilities capabilitySet
8183

8284
// The attributes below detail a virtual policy that we never expose
8385
// directly to the end user.
@@ -127,6 +129,7 @@ func NewACL(management bool, policies []*Policy) (*ACL, error) {
127129
wsvTxn := iradix.New[capabilitySet]().Txn()
128130

129131
operatorCapabilities := make(capabilitySet)
132+
sentinelCapabilities := make(capabilitySet)
130133

131134
for _, policy := range policies {
132135
NAMESPACES:
@@ -299,6 +302,20 @@ func NewACL(management bool, policies []*Policy) (*ACL, error) {
299302
}
300303
}
301304
}
305+
if policy.Sentinel != nil {
306+
acl.sentinel = maxPrivilege(acl.sentinel, policy.Sentinel.Policy)
307+
if !sentinelCapabilities.Check(SentinelCapabilityDeny) {
308+
for _, cap := range policy.Sentinel.Capabilities {
309+
if cap == SentinelCapabilityDeny {
310+
// Deny always takes precedence
311+
sentinelCapabilities.Clear()
312+
sentinelCapabilities.Set(SentinelCapabilityDeny)
313+
break
314+
}
315+
sentinelCapabilities.Set(cap)
316+
}
317+
}
318+
}
302319
if policy.Quota != nil {
303320
acl.quota = maxPrivilege(acl.quota, policy.Quota.Policy)
304321
}
@@ -321,6 +338,7 @@ func NewACL(management bool, policies []*Policy) (*ACL, error) {
321338
acl.wildcardVariables = wsvTxn.Commit()
322339

323340
acl.operatorCapabilities = operatorCapabilities
341+
acl.sentinelCapabilities = sentinelCapabilities
324342

325343
acl.client = PolicyDeny
326344
acl.server = PolicyDeny
@@ -886,6 +904,26 @@ func (a *ACL) AllowOperatorOperation(op string) bool {
886904
}
887905
}
888906

907+
// AllowSentinelOperation checks if a given operation is allowed for sentinel
908+
func (a *ACL) AllowSentinelOperation(op string) bool {
909+
switch {
910+
case a == nil:
911+
return false
912+
case a.aclsDisabled:
913+
return false // Sentinel is entirely disabled when ACLs are disabled
914+
case a.management:
915+
return true
916+
case a.sentinel == PolicyWrite:
917+
return true
918+
case a.server == PolicyWrite:
919+
return true // needed to support cross-region replication
920+
case a.sentinelCapabilities.Check(op):
921+
return true
922+
default:
923+
return false
924+
}
925+
}
926+
889927
// AllowQuotaRead checks if read operations are allowed for all quotas
890928
func (a *ACL) AllowQuotaRead() bool {
891929
switch {

acl/acl_test.go

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1263,3 +1263,135 @@ func TestAllowOperatorOperation(t *testing.T) {
12631263
must.True(t, acl.AllowOperatorOperation(OperatorCapabilityKeyringRotate))
12641264
})
12651265
}
1266+
1267+
func TestAllowSentinelOperation(t *testing.T) {
1268+
ci.Parallel(t)
1269+
1270+
testCases := []struct {
1271+
name string
1272+
policy string
1273+
operation string
1274+
expect bool
1275+
}{
1276+
{
1277+
name: "policy write allows sentinel-read",
1278+
policy: `sentinel { policy = "write" }`,
1279+
operation: SentinelCapabilityRead,
1280+
expect: true,
1281+
},
1282+
{
1283+
name: "policy write allows sentinel-submit",
1284+
policy: `sentinel { policy = "write" }`,
1285+
operation: SentinelCapabilitySubmit,
1286+
expect: true,
1287+
},
1288+
{
1289+
name: "policy write allows sentinel-delete",
1290+
policy: `sentinel { policy = "write" }`,
1291+
operation: SentinelCapabilityDelete,
1292+
expect: true,
1293+
},
1294+
{
1295+
name: "policy read allows sentinel-read",
1296+
policy: `sentinel { policy = "read" }`,
1297+
operation: SentinelCapabilityRead,
1298+
expect: true,
1299+
},
1300+
{
1301+
name: "policy read denies sentinel-submit",
1302+
policy: `sentinel { policy = "read" }`,
1303+
operation: SentinelCapabilitySubmit,
1304+
expect: false,
1305+
},
1306+
{
1307+
name: "policy read denies sentinel-delete",
1308+
policy: `sentinel { policy = "read" }`,
1309+
operation: SentinelCapabilityDelete,
1310+
expect: false,
1311+
},
1312+
{
1313+
name: "policy deny overrides all capabilities",
1314+
policy: `sentinel { policy = "deny"
1315+
capabilities = ["sentinel-read"] }`,
1316+
operation: SentinelCapabilityRead,
1317+
expect: false,
1318+
},
1319+
{
1320+
name: "capability sentinel-submit allows sentinel-submit over read policy",
1321+
policy: `sentinel { policy = "read"
1322+
capabilities = ["sentinel-submit"] }`,
1323+
operation: SentinelCapabilitySubmit,
1324+
expect: true,
1325+
},
1326+
{
1327+
name: "capability sentinel-read allows sentinel-read",
1328+
policy: `sentinel { capabilities = ["sentinel-read"] }`,
1329+
operation: SentinelCapabilityRead,
1330+
expect: true,
1331+
},
1332+
{
1333+
name: "multiple capabilities allow respective operations",
1334+
policy: `sentinel { capabilities = ["sentinel-read", "sentinel-submit"] }`,
1335+
operation: SentinelCapabilitySubmit,
1336+
expect: true,
1337+
},
1338+
{
1339+
name: "capability deny denies all operations",
1340+
policy: `sentinel { capabilities = ["deny"] }`,
1341+
operation: SentinelCapabilityRead,
1342+
expect: false,
1343+
},
1344+
{
1345+
name: "capability deny takes precedence over other capabilities",
1346+
policy: `sentinel { capabilities = ["sentinel-read", "deny"] }`,
1347+
operation: SentinelCapabilityRead,
1348+
expect: false,
1349+
},
1350+
{
1351+
name: "deny everything without a sentinel policy",
1352+
policy: `agent { policy = "read" }`,
1353+
operation: SentinelCapabilityRead,
1354+
expect: false,
1355+
},
1356+
}
1357+
1358+
for _, tc := range testCases {
1359+
t.Run(tc.name, func(t *testing.T) {
1360+
policy, err := Parse(tc.policy, PolicyParseStrict)
1361+
must.NoError(t, err)
1362+
1363+
acl, err := NewACL(false, []*Policy{policy})
1364+
must.NoError(t, err)
1365+
1366+
got := acl.AllowSentinelOperation(tc.operation)
1367+
must.Eq(t, tc.expect, got)
1368+
})
1369+
}
1370+
1371+
t.Run("nil ACL denies all operations", func(t *testing.T) {
1372+
var acl *ACL
1373+
must.False(t, acl.AllowSentinelOperation(SentinelCapabilityRead))
1374+
})
1375+
1376+
t.Run("management token allows all operations", func(t *testing.T) {
1377+
acl, err := NewACL(true, nil)
1378+
must.NoError(t, err)
1379+
must.True(t, acl.AllowSentinelOperation(SentinelCapabilityRead))
1380+
must.True(t, acl.AllowSentinelOperation(SentinelCapabilitySubmit))
1381+
must.True(t, acl.AllowSentinelOperation(SentinelCapabilityDelete))
1382+
})
1383+
1384+
t.Run("ACLs disabled denies all operations", func(t *testing.T) {
1385+
acl := &ACL{aclsDisabled: true}
1386+
must.False(t, acl.AllowSentinelOperation(SentinelCapabilityRead))
1387+
must.False(t, acl.AllowSentinelOperation(SentinelCapabilitySubmit))
1388+
must.False(t, acl.AllowSentinelOperation(SentinelCapabilityDelete))
1389+
})
1390+
1391+
t.Run("server write policy allows all operations", func(t *testing.T) {
1392+
acl := &ACL{server: PolicyWrite}
1393+
must.True(t, acl.AllowSentinelOperation(SentinelCapabilityRead))
1394+
must.True(t, acl.AllowSentinelOperation(SentinelCapabilitySubmit))
1395+
must.True(t, acl.AllowSentinelOperation(SentinelCapabilityDelete))
1396+
})
1397+
}

acl/policy.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,16 @@ const (
144144
OperatorCapabilityKeyringDelete = "keyring-delete"
145145
)
146146

147+
const (
148+
// The following are the fine-grained capabilities that can be granted for
149+
// Sentinel CRUD operations. Deny takes precedence and overwrites all other
150+
// capabilities.
151+
SentinelCapabilityDeny = "deny"
152+
SentinelCapabilityRead = "sentinel-read"
153+
SentinelCapabilitySubmit = "sentinel-submit"
154+
SentinelCapabilityDelete = "sentinel-delete"
155+
)
156+
147157
// Policy represents a parsed HCL or JSON policy.
148158
type Policy struct {
149159
Namespaces []*NamespacePolicy `hcl:"namespace,expand"`
@@ -152,6 +162,7 @@ type Policy struct {
152162
Agent *AgentPolicy `hcl:"agent"`
153163
Node *NodePolicy `hcl:"node"`
154164
Operator *OperatorPolicy `hcl:"operator"`
165+
Sentinel *SentinelPolicy `hcl:"sentinel"`
155166
Quota *QuotaPolicy `hcl:"quota"`
156167
Plugin *PluginPolicy `hcl:"plugin"`
157168
Raw string `hcl:"-"`
@@ -177,6 +188,7 @@ func (p *Policy) IsEmpty() bool {
177188
p.Agent == nil &&
178189
p.Node == nil &&
179190
p.Operator == nil &&
191+
p.Sentinel == nil &&
180192
p.Quota == nil &&
181193
p.Plugin == nil
182194
}
@@ -233,6 +245,11 @@ type OperatorPolicy struct {
233245
Capabilities []string
234246
}
235247

248+
type SentinelPolicy struct {
249+
Policy string
250+
Capabilities []string
251+
}
252+
236253
type QuotaPolicy struct {
237254
Policy string
238255
}
@@ -405,6 +422,17 @@ func isOperatorCapabilityValid(cap string) bool {
405422
}
406423
}
407424

425+
// isSentinelCapabilityValid ensures the given capability is valid for a sentinel policy
426+
func isSentinelCapabilityValid(cap string) bool {
427+
switch cap {
428+
case SentinelCapabilityDeny, SentinelCapabilityRead,
429+
SentinelCapabilitySubmit, SentinelCapabilityDelete:
430+
return true
431+
default:
432+
return false
433+
}
434+
}
435+
408436
func expandNodePoolPolicy(policy string) []string {
409437
switch policy {
410438
case PolicyDeny:
@@ -440,6 +468,21 @@ func expandOperatorPolicy(policy string) []string {
440468
}
441469
}
442470

471+
// expandSentinelPolicy provides the equivalent set of capabilities for
472+
// a sentinel policy
473+
func expandSentinelPolicy(policy string) []string {
474+
switch policy {
475+
case PolicyDeny:
476+
return []string{SentinelCapabilityDeny}
477+
case PolicyRead:
478+
return []string{SentinelCapabilityRead}
479+
case PolicyWrite:
480+
return []string{SentinelCapabilityRead, SentinelCapabilitySubmit, SentinelCapabilityDelete}
481+
default:
482+
return nil
483+
}
484+
}
485+
443486
func isHostVolumeCapabilityValid(cap string) bool {
444487
switch cap {
445488
case HostVolumeCapabilityDeny, HostVolumeCapabilityMountReadOnly, HostVolumeCapabilityMountReadWrite:
@@ -658,6 +701,24 @@ func Parse(rules string, strict bool) (*Policy, error) {
658701
}
659702
}
660703

704+
if p.Sentinel != nil {
705+
if p.Sentinel.Policy != "" && !isPolicyValid(p.Sentinel.Policy) {
706+
return nil, fmt.Errorf("Invalid sentinel policy: %#v", p.Sentinel)
707+
}
708+
for _, cap := range p.Sentinel.Capabilities {
709+
if !isSentinelCapabilityValid(cap) {
710+
return nil, fmt.Errorf("Invalid sentinel capability '%s'", cap)
711+
}
712+
}
713+
714+
// Expand the short hand policy to the capabilities and
715+
// add to any existing capabilities
716+
if p.Sentinel.Policy != "" {
717+
extraCap := expandSentinelPolicy(p.Sentinel.Policy)
718+
p.Sentinel.Capabilities = append(p.Sentinel.Capabilities, extraCap...)
719+
}
720+
}
721+
661722
if p.Quota != nil && !isPolicyValid(p.Quota.Policy) {
662723
return nil, fmt.Errorf("Invalid quota policy: %#v", p.Quota)
663724
}

acl/policy_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@ func TestParse(t *testing.T) {
9999
operator {
100100
policy = "deny"
101101
}
102+
sentinel {
103+
policy = "read"
104+
capabilities = ["sentinel-delete"]
105+
}
102106
quota {
103107
policy = "read"
104108
}
@@ -232,6 +236,13 @@ func TestParse(t *testing.T) {
232236
Policy: PolicyDeny,
233237
Capabilities: []string{"deny"},
234238
},
239+
Sentinel: &SentinelPolicy{
240+
Policy: PolicyRead,
241+
Capabilities: []string{
242+
SentinelCapabilityDelete,
243+
SentinelCapabilityRead,
244+
},
245+
},
235246
Quota: &QuotaPolicy{
236247
Policy: PolicyRead,
237248
},
@@ -940,6 +951,16 @@ func TestParse(t *testing.T) {
940951
"Invalid plugin policy",
941952
nil,
942953
},
954+
{
955+
`sentinel { policy = "invalid" }`,
956+
"Invalid sentinel policy",
957+
nil,
958+
},
959+
{
960+
`sentinel { capabilities = ["invalid-capability"] }`,
961+
"Invalid sentinel capability",
962+
nil,
963+
},
943964
}
944965

945966
for idx, tc := range tcases {

command/sentinel_apply.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Usage: nomad sentinel apply [options] <name> <file>
2626
from stdin by specifying "-".
2727
2828
Sentinel commands are only available when ACLs are enabled. This command
29-
requires a management token.
29+
requires a token with the sentinel-submit capability.
3030
3131
General Options:
3232

command/sentinel_delete.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Usage: nomad sentinel delete [options] <name>
2121
Delete is used to delete an existing Sentinel policy.
2222
2323
Sentinel commands are only available when ACLs are enabled. This command
24-
requires a management token.
24+
requires a token with the sentinel-delete capability.
2525
2626
General Options:
2727

command/sentinel_list.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Usage: nomad sentinel list [options]
2121
List is used to display all the installed Sentinel policies.
2222
2323
Sentinel commands are only available when ACLs are enabled. This command
24-
requires a management token.
24+
requires a token with the sentinel-read capability.
2525
2626
General Options:
2727

0 commit comments

Comments
 (0)