Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion consent-server/internal/consent/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -1020,7 +1020,7 @@ func (s *consentService) ExpireConsent(ctx context.Context, consent *model.Conse
sysExpiredAuthStatus := string(config.Get().Consent.GetSystemExpiredAuthStatus())
currentTime := utils.GetCurrentTimeMillis()

reason := "Consent expired based on expirationTime"
reason := "Consent expired based on expiration time"
actionBy := "SYSTEM"
// For Re-check after locking as expiry can be triggered by multiple request paths and the cron job.
shouldExpire := true
Expand Down
2 changes: 1 addition & 1 deletion portal/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ BFF_AUTH__CLIENT_SECRET=
BFF_AUTH__PORTAL_URL=http://localhost:5173/consents
BFF_AUTH__REDIRECT_URI=http://localhost:8080/auth/callback
BFF_AUTH__POST_LOGOUT_REDIRECT_URI=http://localhost:5173/
BFF_AUTH__SCOPES=openid email profile portal:consents:read:self portal:consents:write:self
BFF_AUTH__SCOPES=openid email profile portal:consents:read:self portal:consents:write:self portal:elements:read portal:elements:write portal:purposes:read portal:purposes:write
BFF_AUTH__RESOURCE_AUDIENCE=
BFF_AUTH__ALLOWED_SIGNING_ALGORITHMS=RS256
BFF_AUTH__HTTP_TIMEOUT=5s
Expand Down
78 changes: 77 additions & 1 deletion portal/backend/internal/me/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import (
systemcontext "github.com/wso2/openfgc/portal/backend/internal/system/context"
)

var errInvalidConsentState = errors.New("invalid consent state")

// Handler serves /me route groups.
type Handler struct {
svc *Service
Expand Down Expand Up @@ -96,7 +98,7 @@ func (h *Handler) ConsentByID(w http.ResponseWriter, r *http.Request) {

baseResp, err := h.svc.ForwardRaw(r, http.MethodGet, "/api/v1/consents/"+url.PathEscape(consentID), func(q url.Values) {
q.Set("details", "true")
q.Del("includeStatusHistory")
q.Set("includeStatusHistory", "true")
}, nil)
if err != nil {
writeProxyError(w, err)
Expand Down Expand Up @@ -180,6 +182,68 @@ func (h *Handler) ConsentApprove(w http.ResponseWriter, r *http.Request) {
}
}

// ConsentReject handles POST /me/consents/{consentId}/reject.
func (h *Handler) ConsentReject(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSONError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
return
}
userID, ok := h.resolveUserID(w, r)
if !ok {
return
}
consentID := r.PathValue("consentId")
if consentID == "" {
writeJSONError(w, http.StatusNotFound, "NOT_FOUND", "consent id not found")
return
}
if !isValidConsentID(consentID) {
writeJSONError(w, http.StatusBadRequest, "INVALID_CONSENT_ID", "invalid consent id")
return
}
body, err := h.readBoundedBody(r)
if err != nil {
writeBodyReadError(w, err)
return
}
if err := validateEmptyObjectPayload(body); err != nil {
writeJSONError(w, http.StatusBadRequest, "INVALID_PAYLOAD", "invalid request payload")
return
}
baseResp, err := h.svc.ForwardRaw(r, http.MethodGet, "/api/v1/consents/"+url.PathEscape(consentID), func(q url.Values) {
q.Set("details", "true")
q.Del("includeStatusHistory")
}, nil)
if err != nil {
writeProxyError(w, err)
return
}
if baseResp.StatusCode != http.StatusOK {
h.svc.WriteUpstreamResponse(w, baseResp)
return
}
if _, owned, ownershipErr := OwnedConsentGroupID(baseResp.Body, userID); ownershipErr != nil {
writeProxyError(w, ownershipErr)
return
} else if !owned {
writeJSONError(w, http.StatusNotFound, "NOT_FOUND", "consent not found")
return
}

payload, trustedGroupID, err := h.svc.BuildRejectionUpdatePayload(baseResp.Body, userID)
if err != nil {
if errors.Is(err, errInvalidConsentState) {
writeJSONError(w, http.StatusConflict, "INVALID_CONSENT_STATE", "only created consents can be rejected")
return
}
writeProxyError(w, err)
return
}
if err := h.svc.ForwardWithGroupID(w, r, http.MethodPut, "/api/v1/consents/"+url.PathEscape(consentID), nil, payload, trustedGroupID); err != nil {
writeProxyError(w, err)
}
}

// ConsentRevoke handles POST /me/consents/{consentId}/revoke.
func (h *Handler) ConsentRevoke(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Expand Down Expand Up @@ -262,6 +326,18 @@ func writeBodyReadError(w http.ResponseWriter, err error) {
writeJSONError(w, http.StatusBadRequest, "INVALID_REQUEST_BODY", "invalid request body")
}

func validateEmptyObjectPayload(body []byte) error {
if strings.TrimSpace(string(body)) == "" {
return nil
}

var payload map[string]json.RawMessage
if err := json.Unmarshal(body, &payload); err != nil || payload == nil || len(payload) > 0 {
return errors.New("expected an empty JSON object")
}
return nil
}

func (h *Handler) resolveUserID(w http.ResponseWriter, r *http.Request) (string, bool) {
principal, ok := systemcontext.PrincipalFromContext(r.Context())
if !ok {
Expand Down
1 change: 1 addition & 0 deletions portal/backend/internal/me/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ func Initialize(mux *http.ServeMux, cfg config.Config, authManager *auth.Manager
mux.Handle("GET /me/consents", authManager.Require(http.HandlerFunc(handler.Consents), auth.ScopeConsentsReadSelf))
mux.Handle("GET /me/consents/{consentId}", authManager.Require(http.HandlerFunc(handler.ConsentByID), auth.ScopeConsentsReadSelf))
mux.Handle("POST /me/consents/{consentId}/approve", authManager.Require(http.HandlerFunc(handler.ConsentApprove), auth.ScopeConsentsWriteSelf))
mux.Handle("POST /me/consents/{consentId}/reject", authManager.Require(http.HandlerFunc(handler.ConsentReject), auth.ScopeConsentsWriteSelf))
mux.Handle("POST /me/consents/{consentId}/revoke", authManager.Require(http.HandlerFunc(handler.ConsentRevoke), auth.ScopeConsentsWriteSelf))

return nil
Expand Down
50 changes: 50 additions & 0 deletions portal/backend/internal/me/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ type consentUpdatePayload struct {
Authorizations []consentAuthorizationPayload `json:"authorizations"`
}

type consentAuthorizationUpdatePayload struct {
Authorizations []consentAuthorizationPayload `json:"authorizations"`
}

// NewService builds a me service from app config.
func NewService(cfg config.ProxyConfig) (*Service, error) {
svc, err := proxy.NewService(cfg)
Expand Down Expand Up @@ -298,6 +302,52 @@ func (s *Service) BuildApprovalUpdatePayload(baseBody []byte, selections []conse
return serializedPayload, consent.GroupID, nil
}

// BuildRejectionUpdatePayload builds the minimal consent update required to reject a consent.
func (s *Service) BuildRejectionUpdatePayload(baseBody []byte, userID string) ([]byte, string, error) {
var consent consentRetrievalResponse
if err := json.Unmarshal(baseBody, &consent); err != nil {
return nil, "", proxy.ErrUpstreamUnavailable
}

if !strings.EqualFold(strings.TrimSpace(consent.Status), "CREATED") {
return nil, "", errInvalidConsentState
}

userID = strings.TrimSpace(userID)
if userID == "" {
return nil, "", proxy.ErrUpstreamUnavailable
}

updatedAuthorizations := make([]consentAuthorizationPayload, len(consent.Authorizations))
currentUserFound := false
for index, authorization := range consent.Authorizations {
status := authorization.Status
if authorization.UserID != nil &&
strings.EqualFold(strings.TrimSpace(*authorization.UserID), userID) {
status = "REJECTED"
currentUserFound = true
}
updatedAuthorizations[index] = consentAuthorizationPayload{
UserID: authorization.UserID,
Type: authorization.Type,
Status: status,
Resources: normalizeAuthorizationResources(authorization.Resources),
}
}
if !currentUserFound {
return nil, "", proxy.ErrUpstreamUnavailable
}

payload := consentAuthorizationUpdatePayload{
Authorizations: updatedAuthorizations,
}
serializedPayload, err := json.Marshal(payload)
if err != nil {
return nil, "", proxy.ErrUpstreamUnavailable
}
return serializedPayload, consent.GroupID, nil
}

func normalizeAuthorizationResources(resources any) any {
if resources == nil {
return map[string]any{}
Expand Down
1 change: 1 addition & 0 deletions portal/backend/internal/system/auth/validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,7 @@ func TestEveryProtectedPolicyAcceptsReconstructedTokenWithRequiredScope(t *testi
{"GET", "/me/consents", ScopeConsentsReadSelf, false},
{"GET", "/me/consents/c1", ScopeConsentsReadSelf, false},
{"POST", "/me/consents/c1/approve", ScopeConsentsWriteSelf, false},
{"POST", "/me/consents/c1/reject", ScopeConsentsWriteSelf, false},
{"POST", "/me/consents/c1/revoke", ScopeConsentsWriteSelf, false},
{"GET", "/api/consents", ScopeConsentsReadAny, true},
{"POST", "/api/consents", ScopeConsentsWriteAny, true},
Expand Down
78 changes: 77 additions & 1 deletion portal/backend/openapi/portal-backend.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,32 @@ paths:
name: consentTypes
description: Comma-separated consent types.
schema: { type: string }
- in: query
name: purposeName
description: Purpose name contained by the consent.
schema: { type: string }
- in: query
name: groupIds
description: Comma-separated group IDs.
schema: { type: string }
- in: query
name: elementName
description: Element name contained by the consent.
schema: { type: string }
- in: query
name: elementVersion
description: >-
Element version contained by the consent. Requires elementName.
schema: { type: string }
- in: query
name: sort
description: >-
Up to three comma-separated sort items in `{field}:{direction}` or
`{field}` form. Supported fields are `createdTime`, `updatedTime`,
`validityTime`, `status`, `groupId`, and `consentType`; directions
are `asc` and `desc`, with `desc` used when omitted. Defaults to
`createdTime:desc` when the parameter is omitted.
schema: { type: string }
- in: query
name: fromTime
description: Inclusive lower bound for updated time, in Unix milliseconds.
Expand Down Expand Up @@ -162,7 +188,7 @@ paths:
description: >-
Fetches one consent with `details=true` and returns its embedded purpose
and element display names and descriptions unchanged. Status audit
history is not exposed by this `/me` route.
history is included by forcing `includeStatusHistory=true` upstream.
parameters:
- $ref: '#/components/parameters/ConsentID'
responses:
Expand Down Expand Up @@ -214,6 +240,43 @@ paths:
'413': { $ref: '#/components/responses/ProxyError' }
'502': { $ref: '#/components/responses/ProxyError' }
'503': { $ref: '#/components/responses/ProxyError' }
/me/consents/{consentId}/reject:
post:
tags: [User]
summary: Reject a pending current-user consent
security:
- PortalOAuthDocumentation: [portal:consents:write:self]
AccessTokenPart2: []
description: >-
Fetches the current consent with `details=true`, verifies ownership and
that its status is `CREATED`, then builds a minimal
`PUT /api/v1/consents/{consentId}` update. Existing authorizations are
preserved and the trusted user's authorization status is changed to
`REJECTED`. Purpose and element approvals are not included in the
update. The consent's immutable `groupId` is sent as the trusted
upstream `group-id` header.
parameters:
- $ref: '#/components/parameters/ConsentID'
requestBody:
required: false
description: No request properties are currently supported.
content:
application/json:
schema:
type: object
additionalProperties: false
responses:
'200':
description: Consent server update response, returned unchanged.
content:
application/json:
schema: { $ref: '#/components/schemas/ConsentDetail' }
'400': { $ref: '#/components/responses/ProxyError' }
'404': { $ref: '#/components/responses/ProxyError' }
'409': { $ref: '#/components/responses/ProxyError' }
'413': { $ref: '#/components/responses/ProxyError' }
'502': { $ref: '#/components/responses/ProxyError' }
'503': { $ref: '#/components/responses/ProxyError' }
/me/consents/{consentId}/revoke:
post:
tags: [User]
Expand Down Expand Up @@ -597,6 +660,19 @@ components:
purposes:
type: array
items: { $ref: '#/components/schemas/ConsentPurpose' }
statusHistory:
type: array
items: { $ref: '#/components/schemas/ConsentStatusAuditItem' }
ConsentStatusAuditItem:
type: object
required: [statusAuditId, currentStatus, actionTime]
properties:
statusAuditId: { type: string }
previousStatus: { type: string }
currentStatus: { type: string }
actionTime: { type: integer, format: int64 }
actionBy: { type: string, nullable: true }
reason: { type: string, nullable: true }
ConsentPurpose:
type: object
required: [purposeId, name, version, elements]
Expand Down
Loading
Loading