Skip to main content

netget/server/kerberos/
actions.rs

1//! Kerberos KDC actions, events and metadata.
2//!
3//! The model decides **policy** and nothing else: whether a principal is admitted, which
4//! refusal to give, and the lifetime, renewability and flags of what it admits. It never sees
5//! a key, a password or a nonce, and nothing it can say produces one. Three actions:
6//!
7//! - `kerberos_issue_ticket` — issue the ticket. Rust builds it, encrypts it and still refuses
8//!   where what is missing is not policy (no configured key, missing pre-authentication), and
9//!   clamps every time to the configured ceiling, the client's request and the TGT.
10//! - `kerberos_require_preauth` — AS only: KDC_ERR_PREAUTH_REQUIRED with ETYPE-INFO2.
11//! - `kerberos_refuse` — a KRB-ERROR with a code from the model's vocabulary
12//!   ([`codec::MODEL_REFUSALS`]), which excludes the two codes the server uses when nobody
13//!   decided, so "the model refused" and "the server failed closed" stay apart on the wire.
14//!
15//! The registry's instance is context-free and can answer nothing; `mod.rs` builds one per
16//! request with [`KerberosProtocol::for_request`], carrying that request's established facts.
17
18use std::sync::{Arc, LazyLock, Mutex};
19
20use anyhow::{anyhow, Context, Result};
21use serde_json::{json, Value};
22
23use super::codec;
24use super::kdc::{Exchange, Grant, Issued, Kdc, Pending};
25use crate::llm::actions::{
26    protocol_trait::{ActionResult, Protocol, Server},
27    ActionDefinition, Parameter, ParameterDefinition,
28};
29use crate::protocol::log_template::LogTemplate;
30use crate::protocol::EventType;
31use crate::state::app_state::AppState;
32
33/// Default ceiling on a ticket's lifetime: ten hours, MIT's and Heimdal's default.
34pub const DEFAULT_MAX_TICKET_LIFETIME_SECS: u64 = 10 * 3600;
35/// Default ceiling on a ticket's renewable lifetime: seven days.
36pub const DEFAULT_MAX_RENEWABLE_LIFETIME_SECS: u64 = 7 * 24 * 3600;
37/// Default tolerated clock difference for pre-authentication and authenticators (RFC 4120's
38/// recommended five minutes).
39pub const DEFAULT_MAX_CLOCK_SKEW_SECS: u64 = 300;
40/// Default requests a single source address may make per minute.
41pub const DEFAULT_MAX_REQUESTS_PER_MINUTE: u64 = 600;
42/// Default realm when none is given.
43pub const DEFAULT_REALM: &str = "NETGET.TEST";
44
45/// Upper bounds on the operator's own limits, so a configuration cannot ask for absurdities.
46pub const MAX_TICKET_LIFETIME_LIMIT_SECS: u64 = 365 * 24 * 3600;
47pub const MAX_CLOCK_SKEW_LIMIT_SECS: u64 = 3600;
48
49/// What one executed action decided, and the reply it built, in execution order. Replies
50/// travel here rather than as raw executor output: one request gets exactly one reply, and
51/// `mod.rs` sends the first record's and logs its decision. Nothing the model can trigger
52/// writes to a socket by any other path.
53#[derive(Debug, Clone)]
54pub struct Record {
55    pub decision: &'static str,
56    pub detail: String,
57    pub reply: Vec<u8>,
58}
59
60/// The facts of the request being answered.
61#[derive(Clone)]
62pub struct RequestContext {
63    pub kdc: Arc<Kdc>,
64    pub pending: Arc<Pending>,
65    pub records: Arc<Mutex<Vec<Record>>>,
66}
67
68/// Kerberos KDC protocol handler.
69pub struct KerberosProtocol {
70    request: Option<RequestContext>,
71}
72
73impl Default for KerberosProtocol {
74    fn default() -> Self {
75        Self::new()
76    }
77}
78
79impl KerberosProtocol {
80    pub fn new() -> Self {
81        Self { request: None }
82    }
83
84    pub fn for_request(ctx: RequestContext) -> Self {
85        Self { request: Some(ctx) }
86    }
87
88    fn ctx(&self) -> Result<&RequestContext> {
89        self.request.as_ref().context(
90            "Kerberos action executed without a request context: a reply can only be built for \
91             a request this KDC received and analysed",
92        )
93    }
94
95    fn record(
96        &self,
97        ctx: &RequestContext,
98        decision: &'static str,
99        detail: String,
100        reply: Vec<u8>,
101    ) -> Result<ActionResult> {
102        let mut r = ctx
103            .records
104            .lock()
105            .map_err(|_| anyhow!("the request's record is unavailable"))?;
106        r.push(Record {
107            decision,
108            detail,
109            reply,
110        });
111        Ok(ActionResult::NoAction)
112    }
113}
114
115fn p(name: &str, type_hint: &str, description: &str) -> Parameter {
116    Parameter {
117        name: name.to_string(),
118        type_hint: type_hint.to_string(),
119        description: description.to_string(),
120        required: false,
121    }
122}
123
124fn common_parameters() -> Vec<Parameter> {
125    vec![
126        p(
127            "client",
128            "string",
129            "Client principal name without the realm (alice, host/x)",
130        ),
131        p("client_realm", "string", "The client's realm"),
132        p(
133            "client_known",
134            "boolean",
135            "Whether a key is configured for the client. Without one no ticket can be issued, \
136             whatever you decide",
137        ),
138        p(
139            "service",
140            "string",
141            "Service principal requested (krbtgt/REALM for a TGT)",
142        ),
143        p(
144            "service_known",
145            "boolean",
146            "Whether a key is configured for the service",
147        ),
148        p(
149            "service_is_tgs",
150            "boolean",
151            "True when the client is asking for a TGT",
152        ),
153        p(
154            "requested_lifetime_secs",
155            "number",
156            "Lifetime the client asked for, or null for 'as long as allowed'",
157        ),
158        p(
159            "requested_renewable_lifetime_secs",
160            "number",
161            "Renewable lifetime the client asked for, or null",
162        ),
163        p(
164            "options",
165            "array",
166            "KDC options the client set: forwardable, proxiable, renewable, renewable_ok, \
167             canonicalize",
168        ),
169        p(
170            "etypes",
171            "array",
172            "Enctypes the client offered, in its order",
173        ),
174        p(
175            "addresses",
176            "array",
177            "Client addresses in the request, usually empty",
178        ),
179        p("source_addr", "string", "Where the request came from"),
180        p(
181            "transport",
182            "string",
183            "How the request arrived: udp, or tcp with a 4-byte length prefix",
184        ),
185        p(
186            "max_ticket_lifetime_secs",
187            "number",
188            "The configured ceiling; a longer lifetime is clamped to it",
189        ),
190        p(
191            "max_renewable_lifetime_secs",
192            "number",
193            "The configured renewable ceiling",
194        ),
195    ]
196}
197
198pub static KERBEROS_AS_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
199    let mut params = common_parameters();
200    params.push(p(
201        "preauth",
202        "string",
203        "'verified': the client proved it knows the password (PA-ENC-TIMESTAMP decrypted with \
204         its key, inside the clock skew, not replayed). 'none': no pre-authentication was sent. \
205         A failed pre-authentication never reaches you; the KDC refuses it itself",
206    ));
207    params.push(p(
208        "preauth_required_by_config",
209        "boolean",
210        "Whether this principal must pre-authenticate before a ticket can be issued",
211    ));
212    EventType::new(
213        "kerberos_as_request",
214        "A client asked for an initial ticket (AS-REQ), usually a TGT. Decide: issue it (with \
215         lifetime and flags), require pre-authentication, or refuse with a Kerberos error. \
216         There is no default: if you return nothing the KDC refuses.",
217        json!({"type": "kerberos_require_preauth"}),
218    )
219    .with_parameters(params)
220    .with_actions(vec![
221        issue_ticket_action(),
222        require_preauth_action(),
223        refuse_action(),
224    ])
225});
226
227pub static KERBEROS_TGS_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
228    let mut params = common_parameters();
229    params.push(p(
230        "tgt_flags",
231        "array",
232        "Flags of the TGT the client presented (verified: it decrypted with this KDC's key)",
233    ));
234    params.push(p(
235        "tgt_expires_in_secs",
236        "number",
237        "Seconds until the TGT expires",
238    ));
239    params.push(p(
240        "tgt_authtime",
241        "string",
242        "When the client originally authenticated",
243    ));
244    params.push(p(
245        "tgt_renew_till",
246        "string",
247        "The TGT's renewable end, or null",
248    ));
249    EventType::new(
250        "kerberos_tgs_request",
251        "An authenticated client (its TGT and authenticator verified) asked for a ticket to a \
252         service (TGS-REQ). Decide: issue it, or refuse (S_PRINCIPAL_UNKNOWN, POLICY, …). \
253         There is no default: if you return nothing the KDC refuses.",
254        json!({"type": "kerberos_issue_ticket", "lifetime_secs": 3600}),
255    )
256    .with_parameters(params)
257    .with_actions(vec![issue_ticket_action(), refuse_action()])
258});
259
260fn issue_ticket_action() -> ActionDefinition {
261    ActionDefinition {
262        name: "kerberos_issue_ticket".to_string(),
263        description: "ADMIT the client: issue the requested ticket. The KDC builds and encrypts \
264                      it. Lifetimes are clamped to the configured ceilings, the client's own \
265                      request and (for a service ticket) its TGT; a flag is granted only when \
266                      you set it AND the client asked for it. Flags you do not set are not \
267                      granted. Refused anyway, with the right error, when the client or service \
268                      has no configured key or pre-authentication is required but missing."
269            .to_string(),
270        parameters: vec![
271            p(
272                "lifetime_secs",
273                "number",
274                "Ticket lifetime in seconds (default and maximum: max_ticket_lifetime_secs)",
275            ),
276            p(
277                "renewable",
278                "boolean",
279                "Make the ticket renewable, if the client asked (default false)",
280            ),
281            p(
282                "renewable_lifetime_secs",
283                "number",
284                "Renewable lifetime in seconds (default and maximum: \
285                 max_renewable_lifetime_secs)",
286            ),
287            p(
288                "forwardable",
289                "boolean",
290                "Make the ticket forwardable, if the client asked (default false)",
291            ),
292            p(
293                "proxiable",
294                "boolean",
295                "Make the ticket proxiable, if the client asked (default false)",
296            ),
297            p(
298                "ok_as_delegate",
299                "boolean",
300                "Mark the service as trusted for delegation (service tickets only)",
301            ),
302        ],
303        example: json!({
304            "type": "kerberos_issue_ticket",
305            "lifetime_secs": 28800,
306            "forwardable": true
307        }),
308        log_template: Some(LogTemplate::new().with_info("-> Kerberos ticket issued")),
309    }
310}
311
312fn require_preauth_action() -> ActionDefinition {
313    ActionDefinition {
314        name: "kerberos_require_preauth".to_string(),
315        description: "Answer KDC_ERR_PREAUTH_REQUIRED: the client must prove it knows the \
316                      password before a ticket is issued. The KDC adds the salt and enctypes. \
317                      Also usable for an unknown principal, to avoid revealing which \
318                      principals exist."
319            .to_string(),
320        parameters: vec![],
321        example: json!({"type": "kerberos_require_preauth"}),
322        log_template: Some(LogTemplate::new().with_info("-> Kerberos pre-authentication required")),
323    }
324}
325
326fn refuse_action() -> ActionDefinition {
327    let names: Vec<String> = codec::MODEL_REFUSALS
328        .iter()
329        .map(|c| {
330            codec::error_name(*c)
331                .trim_start_matches("KDC_ERR_")
332                .to_string()
333        })
334        .collect();
335    ActionDefinition {
336        name: "kerberos_refuse".to_string(),
337        description: format!(
338            "REFUSE the request with a Kerberos error. 'error' is one of: {}.",
339            names.join(", ")
340        ),
341        parameters: vec![
342            Parameter {
343                name: "error".to_string(),
344                type_hint: "string".to_string(),
345                description: "The error, e.g. C_PRINCIPAL_UNKNOWN, S_PRINCIPAL_UNKNOWN, POLICY, \
346                              CLIENT_REVOKED, KEY_EXPIRED"
347                    .to_string(),
348                required: true,
349            },
350            p("text", "string", "Optional e-text shown to the client"),
351        ],
352        example: json!({"type": "kerberos_refuse", "error": "C_PRINCIPAL_UNKNOWN"}),
353        log_template: Some(LogTemplate::new().with_info("-> Kerberos refused")),
354    }
355}
356
357/// A non-negative integer field, or `None` when absent.
358fn secs(action: &Value, key: &str) -> Result<Option<i64>> {
359    match action.get(key) {
360        None | Some(Value::Null) => Ok(None),
361        Some(v) => {
362            let n = v
363                .as_u64()
364                .with_context(|| format!("'{key}' must be a non-negative number of seconds"))?;
365            if n == 0 {
366                return Err(anyhow!("'{key}' must be greater than zero"));
367            }
368            Ok(Some(i64::try_from(n).unwrap_or(i64::MAX)))
369        }
370    }
371}
372
373fn flag(action: &Value, key: &str) -> Result<bool> {
374    match action.get(key) {
375        None | Some(Value::Null) => Ok(false),
376        Some(Value::Bool(b)) => Ok(*b),
377        Some(_) => Err(anyhow!("'{key}' must be true or false")),
378    }
379}
380
381impl Protocol for KerberosProtocol {
382    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
383        Vec::new()
384    }
385
386    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
387        vec![
388            issue_ticket_action(),
389            require_preauth_action(),
390            refuse_action(),
391        ]
392    }
393
394    fn protocol_name(&self) -> &'static str {
395        "Kerberos"
396    }
397
398    fn get_event_types(&self) -> Vec<EventType> {
399        vec![
400            KERBEROS_AS_REQUEST_EVENT.clone(),
401            KERBEROS_TGS_REQUEST_EVENT.clone(),
402        ]
403    }
404
405    fn stack_name(&self) -> &'static str {
406        "ETH>IP>UDP>KERBEROS"
407    }
408
409    fn keywords(&self) -> Vec<&'static str> {
410        vec![
411            "kerberos",
412            "kdc",
413            "kerberos kdc",
414            "krb5",
415            "key distribution center",
416        ]
417    }
418
419    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
420        use crate::protocol::metadata::{
421            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
422        };
423        ProtocolMetadataV2::builder()
424            .connectionless()
425            // Every undecided request gets a KRB-ERROR: KRB_ERR_GENERIC, with e-text saying
426            // whether the model could not be reached or answered nothing usable. Never a
427            // ticket; the model's own refusals cannot use the code.
428            .answers_on_failure()
429            .state(DevelopmentState::Experimental)
430            .privilege_requirement(PrivilegeRequirement::PrivilegedPort(88))
431            .well_known_udp_port(88)
432            .request_only("A KDC only answers AS-REQ and TGS-REQ; it never writes first")
433            .max_inbound_bytes(codec::MAX_MESSAGE_LEN)
434            .implementation(
435                "Kerberos V5 KDC (RFC 4120) on UDP and TCP (4-octet length prefix) on one port. \
436                 ASN.1 types from picky-krb, decoded only after an iterative DER walk bounds \
437                 depth (24), element count (4096) and length (64 KiB). AS exchange with \
438                 PA-ENC-TIMESTAMP pre-authentication verified in Rust (clock skew, replay \
439                 cache) and KDC_ERR_PREAUTH_REQUIRED with PA-ETYPE-INFO2; TGS exchange with the \
440                 TGT, authenticator and its keyed checksum over the request body verified, \
441                 reply under the authenticator subkey when present. aes256- and \
442                 aes128-cts-hmac-sha1-96 (RFC 3962), keys from configured passwords with \
443                 PBKDF2; the TGS key is random per start. No DES, RC4, RFC 8009, FAST, PKINIT, \
444                 PAC, cross-realm, renewal, validation, S4U or user-to-user. Per-source rate \
445                 limit, lookaside cache for retransmissions, KRB_ERR_RESPONSE_TOO_BIG over \
446                 UDP, KRB_ERR_FIELD_TOOLONG over TCP.",
447            )
448            .llm_control(
449                "Policy only: whether a principal is admitted, which Kerberos error a refusal \
450                 carries, whether to demand pre-authentication (including of principals that \
451                 do not exist), and the lifetime, renewable lifetime and flags (forwardable, \
452                 proxiable, renewable, ok-as-delegate) of each ticket within the configured \
453                 ceilings. The model never sees or chooses a key, password, nonce or salt.",
454            )
455            .e2e_testing(
456                "tests/server/kerberos/real_client_test.rs: MIT krb5 kinit/klist/kvno and \
457                 Heimdal kinit/klist/kgetcred each get a TGT from NetGet with a password \
458                 (pre-authenticated), see the model's lifetime and flags in klist, and get a \
459                 service ticket that MIT kvno verifies against a keytab ktutil derived from \
460                 the service's password; a wrong password is refused with \
461                 PREAUTH_FAILED, an unknown principal with the model's error, and an \
462                 unreachable model gives an error, never a ticket. Traffic from both is \
463                 relayed and dissected by tshark's kerberos dissector over UDP and TCP. \
464                 e2e_test.rs and codec_test.rs cover every mechanical refusal, the bounds and \
465                 the fail-closed paths with NetGet's own client codec. Both peers are required: \
466                 the tests fail, not skip, without them.",
467            )
468            .notes(
469                "Experimental: two independent clients complete real sessions against it, but \
470                 nobody has reviewed it beyond its tests and the pair job has not run in CI. \
471                 FAILS CLOSED: an unreachable model, no usable answer or an action that could \
472                 not be executed all produce a KRB-ERROR (decision=fail_closed_*). Passwords \
473                 come only from the principal_passwords startup parameter, which is redacted \
474                 wherever parameters are printed, and appear in no event.",
475            )
476            .build()
477    }
478
479    fn description(&self) -> &'static str {
480        "Kerberos V5 KDC (AS and TGS exchanges) with model-decided admission and ticket policy"
481    }
482
483    fn example_prompt(&self) -> &'static str {
484        "Run a Kerberos KDC for realm NETGET.TEST with alice (password wonderland) and \
485         host/web.netget.test; give alice 8 hour forwardable tickets and refuse everyone else"
486    }
487
488    fn group_name(&self) -> &'static str {
489        "Authentication"
490    }
491
492    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
493        vec![
494            ParameterDefinition {
495                name: "realm".to_string(),
496                type_hint: "string".to_string(),
497                description: "The realm this KDC serves, conventionally upper case".to_string(),
498                required: false,
499                example: json!("NETGET.TEST"),
500                default: Some(json!(DEFAULT_REALM)),
501            },
502            ParameterDefinition {
503                name: "principal_passwords".to_string(),
504                type_hint: "object".to_string(),
505                description: "Principal name -> password, for every user and service this KDC \
506                              knows (e.g. {\"alice\": \"...\", \"host/web.netget.test\": \
507                              \"...\"}). Keys are derived from these at startup; the passwords \
508                              are never shown in events or logs. Required."
509                    .to_string(),
510                required: true,
511                example: json!({"alice": "wonderland", "host/web.netget.test": "s3rvice-key"}),
512                default: None,
513            },
514            ParameterDefinition {
515                name: "no_preauth_principals".to_string(),
516                type_hint: "array".to_string(),
517                description: "Principals that may be issued a ticket without \
518                              pre-authentication (every other principal must pre-authenticate)"
519                    .to_string(),
520                required: false,
521                example: json!(["legacy-user"]),
522                default: None,
523            },
524            ParameterDefinition {
525                name: "max_ticket_lifetime_secs".to_string(),
526                type_hint: "integer".to_string(),
527                description: "Ceiling on any ticket's lifetime; longer grants are clamped"
528                    .to_string(),
529                required: false,
530                example: json!(36000),
531                default: Some(json!(DEFAULT_MAX_TICKET_LIFETIME_SECS)),
532            },
533            ParameterDefinition {
534                name: "max_renewable_lifetime_secs".to_string(),
535                type_hint: "integer".to_string(),
536                description: "Ceiling on any ticket's renewable lifetime".to_string(),
537                required: false,
538                example: json!(604800),
539                default: Some(json!(DEFAULT_MAX_RENEWABLE_LIFETIME_SECS)),
540            },
541            ParameterDefinition {
542                name: "max_clock_skew_secs".to_string(),
543                type_hint: "integer".to_string(),
544                description: "Largest tolerated difference between a client's clock and this \
545                              KDC's for pre-authentication and authenticators"
546                    .to_string(),
547                required: false,
548                example: json!(300),
549                default: Some(json!(DEFAULT_MAX_CLOCK_SKEW_SECS)),
550            },
551            ParameterDefinition {
552                name: "max_requests_per_minute_per_source".to_string(),
553                type_hint: "integer".to_string(),
554                description: "Requests one source address may make per minute; the rest are \
555                              dropped (UDP) or the connection closed (TCP)"
556                    .to_string(),
557                required: false,
558                example: json!(600),
559                default: Some(json!(DEFAULT_MAX_REQUESTS_PER_MINUTE)),
560            },
561        ]
562    }
563
564    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
565        use crate::llm::actions::StartupExamples;
566        let params = json!({
567            "realm": "NETGET.TEST",
568            "principal_passwords": {"alice": "wonderland", "host/web.netget.test": "s3rvice-key"}
569        });
570        StartupExamples::new(
571            json!({
572                "type": "open_server",
573                "port": 88,
574                "base_stack": "kerberos",
575                "startup_params": params,
576                "instruction": "Require pre-authentication. Issue alice 8 hour forwardable \
577                                tickets and service tickets for host/web.netget.test; refuse \
578                                every other principal with C_PRINCIPAL_UNKNOWN."
579            }),
580            json!({
581                "type": "open_server",
582                "port": 88,
583                "base_stack": "kerberos",
584                "startup_params": params,
585                "event_handlers": [{
586                    "event_pattern": "kerberos_as_request",
587                    "handler": {
588                        "type": "script",
589                        "language": "python",
590                        "code": "respond([{'type': 'kerberos_issue_ticket', 'lifetime_secs': 28800}] if event['preauth'] == 'verified' and event['client'] == 'alice' else [{'type': 'kerberos_require_preauth'}] if event['client'] == 'alice' else [{'type': 'kerberos_refuse', 'error': 'C_PRINCIPAL_UNKNOWN'}])"
591                    }
592                }]
593            }),
594            json!({
595                "type": "open_server",
596                "port": 88,
597                "base_stack": "kerberos",
598                "startup_params": params,
599                "event_handlers": [{
600                    "event_pattern": "*",
601                    "handler": {
602                        "type": "static",
603                        "actions": [{"type": "kerberos_refuse", "error": "POLICY", "text": "KDC closed"}]
604                    }
605                }]
606            }),
607        )
608    }
609}
610
611impl Server for KerberosProtocol {
612    fn spawn(
613        &self,
614        ctx: crate::protocol::SpawnContext,
615    ) -> std::pin::Pin<
616        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
617    > {
618        Box::pin(async move { super::KerberosServer::spawn(ctx).await })
619    }
620
621    fn execute_action(&self, action: Value) -> Result<ActionResult> {
622        let action_type = action
623            .get("type")
624            .and_then(|v| v.as_str())
625            .context("Missing 'type' field in action")?;
626        let ctx = self.ctx()?;
627        let pending = ctx.pending.as_ref();
628        let kdc = ctx.kdc.as_ref();
629        match action_type {
630            "kerberos_issue_ticket" => {
631                let grant = Grant {
632                    lifetime_secs: secs(&action, "lifetime_secs")?,
633                    renewable_lifetime_secs: secs(&action, "renewable_lifetime_secs")?,
634                    renewable: flag(&action, "renewable")?,
635                    forwardable: flag(&action, "forwardable")?,
636                    proxiable: flag(&action, "proxiable")?,
637                    ok_as_delegate: flag(&action, "ok_as_delegate")?,
638                };
639                match kdc.issue(pending, &grant)? {
640                    Issued::Ticket {
641                        reply,
642                        summary,
643                        clamped,
644                    } => self.record(
645                        ctx,
646                        if clamped {
647                            "model_issue_clamped"
648                        } else {
649                            "model_issue"
650                        },
651                        summary.to_string(),
652                        reply,
653                    ),
654                    Issued::Enforced {
655                        reply,
656                        decision,
657                        detail,
658                    } => self.record(ctx, decision, detail, reply),
659                }
660            }
661            "kerberos_require_preauth" => {
662                if pending.exchange != Exchange::As {
663                    return Err(anyhow!(
664                        "kerberos_require_preauth applies to AS requests only; refuse a TGS \
665                         request with kerberos_refuse"
666                    ));
667                }
668                let reply = kdc.preauth_required(pending)?;
669                self.record(
670                    ctx,
671                    "model_require_preauth",
672                    format!("{}: pre-authentication required", pending.client),
673                    reply,
674                )
675            }
676            "kerberos_refuse" => {
677                let code = codec::model_refusal_code(
678                    action
679                        .get("error")
680                        .context("kerberos_refuse requires 'error'")?,
681                )?;
682                let text = action.get("text").and_then(|v| v.as_str());
683                if let Some(t) = text {
684                    if t.len() > 256 {
685                        return Err(anyhow!("'text' is limited to 256 bytes"));
686                    }
687                    if !t.is_ascii() {
688                        return Err(anyhow!("'text' must be ASCII (KerberosString is IA5)"));
689                    }
690                }
691                let reply = kdc.refuse(pending, code, text);
692                self.record(
693                    ctx,
694                    "model_reject",
695                    format!("{}: {}", pending.client, codec::error_name(code)),
696                    reply,
697                )
698            }
699            other => Err(anyhow!("Unknown Kerberos KDC action: {other}")),
700        }
701    }
702}