Skip to content

Commit 9ed798c

Browse files
fixes #3680 add revocation management API, CLI, and enforcement
- adds Management API endpoints for revocations (POST, GET, LIST) with type-aware validation (JTI/API_SESSION require UUID, IDENTITY requires existing identity) - adds CLI commands: ziti edge create revocation identity|api-session|jti - adds revocation checks to resolveOidcSession in security_ctx.go so the REST API returns 401 for revoked OIDC tokens. Previously only ValidateAccessToken (router ctrl channel path) checked revocations, so revoked tokens still received 200 OK from the management and client HTTP APIs - adds api-session revocation check to ValidateAccessToken, which only checked JTI and identity revocations - adds Type field to Revocation model, store, and protobuf message - adds integration tests covering CRUD, input validation, and token enforcement for all three revocation types use working edge-api branch name update ref
1 parent 82d192f commit 9ed798c

File tree

19 files changed

+3137
-1585
lines changed

19 files changed

+3137
-1585
lines changed

common/pb/edge_cmd_pb/edge_cmd.pb.go

Lines changed: 2490 additions & 1196 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

common/pb/edge_cmd_pb/edge_cmd.proto

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,7 @@ message Revocation {
440440
string id = 1;
441441
google.protobuf.Timestamp expiresAt = 2;
442442
map<string, TagValue> tags = 3;
443+
string type = 4;
443444
}
444445

445446
message DeleteRevocationsBatchCommand {

controller/db/revocation_store.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,13 @@ import (
2525

2626
const (
2727
FieldRevocationExpiresAt = "expiresAt"
28+
FieldRevocationType = "type"
2829
)
2930

3031
type Revocation struct {
3132
boltz.BaseExtEntity
3233
ExpiresAt time.Time `json:"expiresAt"`
34+
Type string `json:"type"`
3335
}
3436

3537
func (r Revocation) GetEntityType() string {
@@ -56,6 +58,7 @@ type revocationStoreImpl struct {
5658
func (store *revocationStoreImpl) initializeLocal() {
5759
store.AddExtEntitySymbols()
5860
store.AddSymbol(FieldRevocationExpiresAt, ast.NodeTypeDatetime)
61+
store.AddSymbol(FieldRevocationType, ast.NodeTypeString)
5962
}
6063

6164
func (store *revocationStoreImpl) initializeLinked() {}
@@ -67,9 +70,11 @@ func (store *revocationStoreImpl) NewEntity() *Revocation {
6770
func (store *revocationStoreImpl) FillEntity(entity *Revocation, bucket *boltz.TypedBucket) {
6871
entity.LoadBaseValues(bucket)
6972
entity.ExpiresAt = bucket.GetTimeOrError(FieldRevocationExpiresAt)
73+
entity.Type = bucket.GetStringOrError(FieldRevocationType)
7074
}
7175

7276
func (store *revocationStoreImpl) PersistEntity(entity *Revocation, ctx *boltz.PersistContext) {
7377
entity.SetBaseValues(ctx)
7478
ctx.SetTimeP(FieldRevocationExpiresAt, &entity.ExpiresAt)
79+
ctx.SetString(FieldRevocationType, entity.Type)
7580
}

controller/env/appenv.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,16 @@ func (ae *AppEnv) ValidateAccessToken(token string) (*common.AccessClaims, error
235235
return nil, errors.New("access token has been revoked by identity")
236236
}
237237

238+
apiSessionRevocation, err := ae.GetManagers().Revocation.Read(accessClaims.ApiSessionId)
239+
240+
if err != nil && !boltz.IsErrNotFoundErr(err) {
241+
return nil, err
242+
}
243+
244+
if apiSessionRevocation != nil && apiSessionRevocation.CreatedAt.After(accessClaims.IssuedAt.AsTime()) {
245+
return nil, errors.New("access token has been revoked by api session")
246+
}
247+
238248
return accessClaims, nil
239249
}
240250

controller/env/security_ctx.go

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -397,25 +397,34 @@ func (ctx *SecurityCtx) resolveOidcSession(securityToken *common.SecurityToken)
397397
return
398398
}
399399

400-
// Check if this specific token has been revoked by JWTID.
401-
tokenRevocation, err := ctx.env.GetManagers().Revocation.Read(claims.JWTID)
400+
// Check revocations before accepting the session.
401+
jtiRevocation, err := ctx.env.GetManagers().Revocation.Read(claims.JWTID)
402402
if err != nil && !boltz.IsErrNotFoundErr(err) {
403403
ctx.setApiSessionError(errorz.NewUnauthorizedOidcInvalid())
404404
return
405405
}
406-
if tokenRevocation != nil {
406+
if jtiRevocation != nil {
407407
ctx.setApiSessionError(errorz.NewUnauthorizedOidcInvalid())
408408
return
409409
}
410410

411-
// Check if the issuing identity has been terminated via a high-water-mark revocation.
412411
identityRevocation, err := ctx.env.GetManagers().Revocation.Read(claims.Subject)
413412
if err != nil && !boltz.IsErrNotFoundErr(err) {
414413
ctx.setApiSessionError(errorz.NewUnauthorizedOidcInvalid())
415414
return
416415
}
417-
if identityRevocation != nil && identityRevocation.CreatedAt.Truncate(time.Second).After(claims.IssuedAt.AsTime()) {
418-
ctx.setApiSessionError(errorz.NewUnauthorizedOidcExpired())
416+
if identityRevocation != nil && identityRevocation.CreatedAt.After(claims.IssuedAt.AsTime()) {
417+
ctx.setApiSessionError(errorz.NewUnauthorizedOidcInvalid())
418+
return
419+
}
420+
421+
apiSessionRevocation, err := ctx.env.GetManagers().Revocation.Read(claims.ApiSessionId)
422+
if err != nil && !boltz.IsErrNotFoundErr(err) {
423+
ctx.setApiSessionError(errorz.NewUnauthorizedOidcInvalid())
424+
return
425+
}
426+
if apiSessionRevocation != nil {
427+
ctx.setApiSessionError(errorz.NewUnauthorizedOidcInvalid())
419428
return
420429
}
421430

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
Copyright NetFoundry Inc.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
https://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package routes
18+
19+
import (
20+
"github.com/go-openapi/strfmt"
21+
"github.com/openziti/edge-api/rest_model"
22+
"github.com/openziti/ziti/v2/controller/env"
23+
"github.com/openziti/ziti/v2/controller/model"
24+
"github.com/openziti/ziti/v2/controller/response"
25+
)
26+
27+
const EntityNameRevocations = "revocations"
28+
29+
// RevocationLinkFactory is the link factory for revocation entities.
30+
var RevocationLinkFactory = NewBasicLinkFactory(EntityNameRevocations)
31+
32+
// MapRevocationToRestEntity maps a model Revocation to its REST representation for use
33+
// with ListWithHandler and DetailWithHandler.
34+
func MapRevocationToRestEntity(_ *env.AppEnv, _ *response.RequestContext, revocationModel *model.Revocation) (interface{}, error) {
35+
return MapRevocationToRestModel(revocationModel)
36+
}
37+
38+
// MapRevocationToRestModel converts a model Revocation to a rest_model RevocationDetail.
39+
func MapRevocationToRestModel(revocation *model.Revocation) (*rest_model.RevocationDetail, error) {
40+
expiresAt := strfmt.DateTime(revocation.ExpiresAt)
41+
revocationType := rest_model.RevocationTypeEnum(revocation.Type)
42+
43+
ret := &rest_model.RevocationDetail{
44+
BaseEntity: BaseEntityToRestModel(revocation, RevocationLinkFactory),
45+
ExpiresAt: &expiresAt,
46+
Type: &revocationType,
47+
}
48+
49+
return ret, nil
50+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*
2+
Copyright NetFoundry Inc.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
https://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package routes
18+
19+
import (
20+
"net/http"
21+
"time"
22+
23+
"github.com/go-openapi/runtime/middleware"
24+
"github.com/google/uuid"
25+
"github.com/openziti/edge-api/rest_management_api_server/operations/revocation"
26+
"github.com/openziti/edge-api/rest_model"
27+
"github.com/openziti/foundation/v2/errorz"
28+
"github.com/openziti/storage/boltz"
29+
"github.com/openziti/ziti/v2/controller/env"
30+
"github.com/openziti/ziti/v2/controller/model"
31+
"github.com/openziti/ziti/v2/controller/models"
32+
"github.com/openziti/ziti/v2/controller/permissions"
33+
"github.com/openziti/ziti/v2/controller/response"
34+
)
35+
36+
func init() {
37+
r := NewRevocationRouter()
38+
env.AddRouter(r)
39+
}
40+
41+
// RevocationRouter handles Management API routes for revocation management.
42+
type RevocationRouter struct {
43+
BasePath string
44+
}
45+
46+
// NewRevocationRouter creates a new RevocationRouter.
47+
func NewRevocationRouter() *RevocationRouter {
48+
return &RevocationRouter{
49+
BasePath: "/" + EntityNameRevocations,
50+
}
51+
}
52+
53+
func (r *RevocationRouter) Register(ae *env.AppEnv) {
54+
ae.ManagementApi.RevocationListRevocationsHandler = revocation.ListRevocationsHandlerFunc(func(params revocation.ListRevocationsParams, _ interface{}) middleware.Responder {
55+
ae.InitPermissionsContext(params.HTTPRequest, permissions.Management, "revocation", permissions.Read)
56+
return ae.IsAllowed(r.List, params.HTTPRequest, "", "", permissions.DefaultManagementAccess())
57+
})
58+
59+
ae.ManagementApi.RevocationDetailRevocationHandler = revocation.DetailRevocationHandlerFunc(func(params revocation.DetailRevocationParams, _ interface{}) middleware.Responder {
60+
ae.InitPermissionsContext(params.HTTPRequest, permissions.Management, "revocation", permissions.Read)
61+
return ae.IsAllowed(r.Detail, params.HTTPRequest, params.ID, "", permissions.DefaultManagementAccess())
62+
})
63+
64+
ae.ManagementApi.RevocationCreateRevocationHandler = revocation.CreateRevocationHandlerFunc(func(params revocation.CreateRevocationParams, _ interface{}) middleware.Responder {
65+
ae.InitPermissionsContext(params.HTTPRequest, permissions.Management, "revocation", permissions.Create)
66+
return ae.IsAllowed(func(ae *env.AppEnv, rc *response.RequestContext) { r.Create(ae, rc, params) }, params.HTTPRequest, "", "", permissions.DefaultManagementAccess())
67+
})
68+
}
69+
70+
func (r *RevocationRouter) List(ae *env.AppEnv, rc *response.RequestContext) {
71+
ListWithHandler[*model.Revocation](ae, rc, ae.Managers.Revocation, MapRevocationToRestEntity)
72+
}
73+
74+
func (r *RevocationRouter) Detail(ae *env.AppEnv, rc *response.RequestContext) {
75+
DetailWithHandler[*model.Revocation](ae, rc, ae.Managers.Revocation, MapRevocationToRestEntity)
76+
}
77+
78+
// Create handles POST /revocations. It validates the submitted id against the
79+
// revocation type, computes the expiry from the configured refresh token duration,
80+
// and persists the revocation entry.
81+
func (r *RevocationRouter) Create(ae *env.AppEnv, rc *response.RequestContext, params revocation.CreateRevocationParams) {
82+
Create(rc, rc, RevocationLinkFactory, func() (string, error) {
83+
entity, err := mapCreateRevocationToModel(ae, rc.Request, params.Revocation)
84+
if err != nil {
85+
return "", err
86+
}
87+
if err = ae.Managers.Revocation.Create(entity, rc.NewChangeContext()); err != nil {
88+
return "", err
89+
}
90+
return entity.Id, nil
91+
})
92+
}
93+
94+
// mapCreateRevocationToModel converts a RevocationCreate REST model to a model.Revocation,
95+
// validating the id against the revocation type and computing the server-side expiry.
96+
func mapCreateRevocationToModel(ae *env.AppEnv, r *http.Request, create *rest_model.RevocationCreate) (*model.Revocation, error) {
97+
if create == nil {
98+
return nil, errorz.NewUnhandled(nil)
99+
}
100+
101+
id := ""
102+
if create.ID != nil {
103+
id = *create.ID
104+
}
105+
106+
revocationType := rest_model.RevocationTypeEnumJTI
107+
if create.Type != nil {
108+
revocationType = *create.Type
109+
}
110+
111+
switch revocationType {
112+
case rest_model.RevocationTypeEnumIDENTITY:
113+
if _, err := ae.Managers.Identity.Read(id); err != nil {
114+
if boltz.IsErrNotFoundErr(err) {
115+
return nil, errorz.NewNotFound()
116+
}
117+
return nil, err
118+
}
119+
case rest_model.RevocationTypeEnumJTI, rest_model.RevocationTypeEnumAPISESSION:
120+
if _, err := uuid.Parse(id); err != nil {
121+
return nil, errorz.NewFieldError("must be a valid UUID v4", "id", id)
122+
}
123+
}
124+
125+
expiresAt := time.Now().Add(ae.GetConfig().Edge.Oidc.RefreshTokenDuration)
126+
127+
return &model.Revocation{
128+
BaseEntity: models.BaseEntity{
129+
Id: id,
130+
Tags: TagsOrDefault(create.Tags),
131+
},
132+
ExpiresAt: expiresAt,
133+
Type: string(revocationType),
134+
}, nil
135+
}

controller/model/revocation_manager.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ func (self *RevocationManager) Marshall(entity *Revocation) ([]byte, error) {
118118
Id: entity.Id,
119119
ExpiresAt: timePtrToPb(&entity.ExpiresAt),
120120
Tags: tags,
121+
Type: entity.Type,
121122
}
122123

123124
return proto.Marshal(msg)
@@ -139,6 +140,7 @@ func (self *RevocationManager) Unmarshall(bytes []byte) (*Revocation, error) {
139140
Tags: edge_cmd_pb.DecodeTags(msg.Tags),
140141
},
141142
ExpiresAt: *pbTimeToTimePtr(msg.ExpiresAt),
143+
Type: msg.Type,
142144
}, nil
143145
}
144146

controller/model/revocation_model.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
type Revocation struct {
2929
models.BaseEntity
3030
ExpiresAt time.Time
31+
Type string
3132
}
3233

3334
func (entity *Revocation) toBoltEntityForUpdate(tx *bbolt.Tx, env Env, _ boltz.FieldChecker) (*db.Revocation, error) {
@@ -37,6 +38,7 @@ func (entity *Revocation) toBoltEntityForUpdate(tx *bbolt.Tx, env Env, _ boltz.F
3738
func (entity *Revocation) fillFrom(_ Env, _ *bbolt.Tx, boltRevocation *db.Revocation) error {
3839
entity.FillCommon(boltRevocation)
3940
entity.ExpiresAt = boltRevocation.ExpiresAt
41+
entity.Type = boltRevocation.Type
4042

4143
return nil
4244
}
@@ -45,6 +47,7 @@ func (entity *Revocation) toBoltEntityForCreate(*bbolt.Tx, Env) (*db.Revocation,
4547
boltEntity := &db.Revocation{
4648
BaseExtEntity: *boltz.NewExtEntity(entity.Id, entity.Tags),
4749
ExpiresAt: entity.ExpiresAt,
50+
Type: entity.Type,
4851
}
4952

5053
return boltEntity, nil

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ require (
6161
github.com/openziti/agent v1.0.33
6262
github.com/openziti/channel/v4 v4.3.9
6363
github.com/openziti/cobra-to-md v1.0.1
64-
github.com/openziti/edge-api v0.27.5
64+
github.com/openziti/edge-api v0.27.6-0.20260408211105-2218206e933b
6565
github.com/openziti/foundation/v2 v2.0.90
6666
github.com/openziti/identity v1.0.128
6767
github.com/openziti/jwks v1.0.6

0 commit comments

Comments
 (0)