feat: support permanent credentials and overlapping token rotation

This commit is contained in:
2026-09-27 11:45:15 +08:00
parent 87aa710013
commit 385a618154
29 changed files with 718 additions and 161 deletions

View File

@@ -117,16 +117,16 @@ func (s *ConsoleApplicationStore) GetApplication(ctx context.Context, token auth
if err != nil {
return err
}
rows, err = tx.QueryContext(ctx, `SELECT id,name,revision,expires_at,revoked_at,permissions FROM orbit_credentials WHERE namespace_id=? ORDER BY id LIMIT 101`, namespace)
rows, err = tx.QueryContext(ctx, `SELECT id,name,revision,expires_at,revoked_at,previous_valid_until,permissions FROM orbit_credentials WHERE namespace_id=? ORDER BY id LIMIT 101`, namespace)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var c consoleauth.AccessCredential
var expires, revoked sql.NullTime
var expires, revoked, previous sql.NullTime
var raw []byte
if err := rows.Scan(&c.ID, &c.Name, &c.Revision, &expires, &revoked, &raw); err != nil {
if err := rows.Scan(&c.ID, &c.Name, &c.Revision, &expires, &revoked, &previous, &raw); err != nil {
return err
}
if err := json.Unmarshal(raw, &c.Permissions); err != nil {
@@ -134,6 +134,7 @@ func (s *ConsoleApplicationStore) GetApplication(ctx context.Context, token auth
}
c.ExpiresAt = nullableTime(expires)
c.RevokedAt = nullableTime(revoked)
c.PreviousValidUntil = nullableTime(previous)
out.Credentials = append(out.Credentials, c)
}
if len(out.Queues) > 100 {
@@ -181,8 +182,14 @@ func applicationPermissions(in consoleauth.ApplicationInput) (auth.Permissions,
return p, p.Validate()
}
func validateApplicationInput(in consoleauth.ApplicationInput) error {
if in.ValidDays < 1 || in.ValidDays > 365 {
return fault.Invalid("valid_days must be 1-365")
if in.ValidDays < 0 || in.ValidDays > 365 {
return fault.Invalid("valid_days must be 0 (permanent) or 1-365")
}
if in.GraceDays < 0 || in.GraceDays > 30 || (in.Kind != "ROTATE" && in.GraceDays != 0) {
return fault.Invalid("grace_days must be 0-30 for rotation only")
}
if in.Kind == "FINALIZE" && in.ValidDays != 0 {
return fault.Invalid("finalize cannot set validity")
}
if err := validCredentialReason(in.Reason); err != nil {
return err
@@ -194,17 +201,17 @@ func validateApplicationInput(in consoleauth.ApplicationInput) error {
}
_, err := applicationPermissions(in)
return err
case "ROTATE":
case "ROTATE", "SET_EXPIRY", "FINALIZE":
if in.NamespaceID.Validate() != nil || in.CredentialID.Validate() != nil || in.ExpectedRevision == 0 || in.Application != "" || in.Namespace != "" || in.Environment != "" || in.Queue != "" || len(in.Capabilities) != 0 {
return fault.Invalid("invalid rotation fields")
}
default:
return fault.Invalid("kind must be CREATE or ROTATE")
return fault.Invalid("kind must be CREATE, ROTATE, SET_EXPIRY or FINALIZE")
}
return nil
}
func (s *ConsoleApplicationStore) PrepareApplication(ctx context.Context, token auth.Token, in consoleauth.ApplicationInput) (consoleauth.ApplicationOperation, error) {
out := consoleauth.ApplicationOperation{Input: in}
out := consoleauth.ApplicationOperation{Version: 2, Input: in}
if err := validateApplicationInput(in); err != nil {
return out, err
}
@@ -216,7 +223,12 @@ func (s *ConsoleApplicationStore) PrepareApplication(ctx context.Context, token
if err != nil {
return err
}
out.ExpiresAt = now.Add(time.Duration(in.ValidDays) * 24 * time.Hour)
if in.ValidDays > 0 {
out.ExpiresAt = timePtr(now.Add(time.Duration(in.ValidDays) * 24 * time.Hour))
}
if in.GraceDays > 0 {
out.PreviousValidUntil = timePtr(now.Add(time.Duration(in.GraceDays) * 24 * time.Hour))
}
out.OperationID, err = core.NewID()
if err != nil {
return err
@@ -243,7 +255,9 @@ func (s *ConsoleApplicationStore) PrepareApplication(ctx context.Context, token
return fault.Conflict
}
}
out.Token, _, err = auth.Issue(out.NamespaceID, out.CredentialID)
if in.Kind == "CREATE" || in.Kind == "ROTATE" {
out.Token, _, err = auth.Issue(out.NamespaceID, out.CredentialID)
}
return err
})
return out, err
@@ -253,15 +267,21 @@ func (s *ConsoleApplicationStore) ApplyApplication(ctx context.Context, token au
if err := validateApplicationInput(op.Input); err != nil {
return out, err
}
parsed, err := auth.Parse(op.Token)
if err != nil || parsed.NamespaceID != op.NamespaceID || parsed.CredentialID != op.CredentialID || op.OperationID.Validate() != nil || op.ApplicationID.Validate() != nil || op.ExpiresAt.IsZero() {
return out, fault.Invalid("invalid fixed operation")
if (op.Version != 0 && op.Version != 2) || op.OperationID.Validate() != nil || op.ApplicationID.Validate() != nil || op.NamespaceID.Validate() != nil || op.CredentialID.Validate() != nil {
return out, fault.Invalid("invalid fixed operation identity")
}
if op.Input.Kind == "ROTATE" {
if (op.Input.ValidDays == 0) != (op.ExpiresAt == nil) || (op.Input.GraceDays == 0) != (op.PreviousValidUntil == nil) {
return out, fault.Invalid("fixed lifetime does not match input")
}
version := op.Version
if version == 0 {
version = 1
}
if op.Input.Kind != "CREATE" {
if op.QueueID != "" || op.NamespaceID != op.Input.NamespaceID || op.CredentialID != op.Input.CredentialID {
return out, fault.Invalid("rotation identity mismatch")
return out, fault.Invalid("credential identity mismatch")
}
change := CredentialOperation{Version: 1, OperationID: op.OperationID, NamespaceID: op.NamespaceID, CredentialID: op.CredentialID, ExpectedRevision: op.Input.ExpectedRevision, Action: "ROTATE", Reason: op.Input.Reason, Token: op.Token, ExpiresAt: &op.ExpiresAt}
change := CredentialOperation{Version: version, OperationID: op.OperationID, NamespaceID: op.NamespaceID, CredentialID: op.CredentialID, ExpectedRevision: op.Input.ExpectedRevision, Action: op.Input.Kind, Reason: op.Input.Reason, Token: op.Token, ExpiresAt: op.ExpiresAt, PreviousValidUntil: op.PreviousValidUntil}
receipt, err := applyCredentialOperation(ctx, s.db, change, func(tx *sql.Tx) (string, error) {
actor, err := s.admin(ctx, tx, token)
if err != nil {
@@ -282,6 +302,10 @@ func (s *ConsoleApplicationStore) ApplyApplication(ctx context.Context, token au
})
return consoleauth.ApplicationReceipt{OperationID: op.OperationID, ApplicationID: op.ApplicationID, NamespaceID: op.NamespaceID, CredentialID: op.CredentialID, Revision: receipt.Revision, Deduplicated: receipt.Deduplicated}, err
}
parsed, err := auth.Parse(op.Token)
if err != nil || parsed.NamespaceID != op.NamespaceID || parsed.CredentialID != op.CredentialID || (version == 1 && op.ExpiresAt == nil) {
return out, fault.Invalid("invalid new credential")
}
if op.QueueID.Validate() != nil {
return out, fault.Invalid("invalid queue identity")
}
@@ -325,7 +349,7 @@ func (s *ConsoleApplicationStore) ApplyApplication(ctx context.Context, token au
if err != nil {
return err
}
if !op.ExpiresAt.After(now) || op.ExpiresAt.After(now.Add(365*24*time.Hour)) {
if op.ExpiresAt != nil && (!op.ExpiresAt.After(now) || op.ExpiresAt.After(now.Add(365*24*time.Hour))) {
return fault.Invalid("expired or excessive credential lifetime")
}
statements := []struct {
@@ -336,7 +360,7 @@ func (s *ConsoleApplicationStore) ApplyApplication(ctx context.Context, token au
{`INSERT INTO orbit_application_resources(application_id) VALUES (?)`, []any{op.ApplicationID}},
{`INSERT INTO orbit_namespaces(id,application_id,name,environment) VALUES (?,?,?,?)`, []any{op.NamespaceID, op.ApplicationID, op.Input.Namespace, op.Input.Environment}},
{`INSERT INTO pulse_queues(namespace_id,id,name,max_concurrency,rate_per_second,policy) VALUES (?,?,?,4,10,'{}')`, []any{op.NamespaceID, op.QueueID, op.Input.Queue}},
{`INSERT INTO orbit_credentials(namespace_id,id,name,secret_digest,permissions,expires_at) VALUES (?,?,'console-issued',?,?,?)`, []any{op.NamespaceID, op.CredentialID, parsed.Digest, string(raw), op.ExpiresAt.UTC()}},
{`INSERT INTO orbit_credentials(namespace_id,id,name,secret_digest,permissions,expires_at) VALUES (?,?,'console-issued',?,?,?)`, []any{op.NamespaceID, op.CredentialID, parsed.Digest, string(raw), op.ExpiresAt}},
}
for _, st := range statements {
if _, err := tx.ExecContext(ctx, st.query, st.args...); err != nil {