001/*
002 * Copyright 2022-2026 Revetware LLC.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package com.soklet;
018
019import org.jspecify.annotations.NonNull;
020import org.jspecify.annotations.Nullable;
021
022import javax.annotation.concurrent.NotThreadSafe;
023import javax.annotation.concurrent.ThreadSafe;
024import java.nio.CharBuffer;
025import java.nio.charset.CodingErrorAction;
026import java.nio.charset.CharacterCodingException;
027import java.nio.charset.StandardCharsets;
028import java.util.Optional;
029
030import static java.util.Objects.requireNonNull;
031
032/**
033 * Shallowly immutable application-supplied identity and stable, opaque
034 * partition keys. The carrier itself is safe for concurrent access;
035 * applications retain responsibility for the thread-safety of principal and
036 * application-context objects they place inside it.
037 *
038 * <p>Instances intentionally retain reference identity. Principal and
039 * application-context objects are opaque security capabilities and do not
040 * participate in framework-defined structural equality. Authorization code
041 * must compare the documented stable partition keys or apply its own
042 * principal policy explicitly.
043 *
044 * @author <a href="https://www.revetkn.com">Mark Allen</a>
045 */
046@ThreadSafe
047public final class McpAdmissionIdentity {
048        /** Maximum UTF-8 encoding length of either partition key. */
049        @NonNull
050        public static final Integer MAXIMUM_PARTITION_KEY_SIZE_IN_UTF_8_BYTES = 256;
051        @NonNull
052        private static final McpAdmissionIdentity ANONYMOUS =
053                        new Builder("anonymous").build();
054        @NonNull
055        private final String rateLimitPartitionKey;
056        @Nullable
057        private final String authorizationPartitionKey;
058        @Nullable
059        private final Object principal;
060        @Nullable
061        private final Object applicationContext;
062
063        /**
064         * Returns the canonical anonymous identity.
065         *
066         * @return shared anonymous identity
067         */
068        @NonNull
069        public static McpAdmissionIdentity anonymousInstance() {
070                return ANONYMOUS;
071        }
072
073        /**
074         * Vends an identity builder primed with a stable rate-limit partition key.
075         *
076         * @param rateLimitPartitionKey opaque, fleet-stable partition key
077         * @return identity builder
078         */
079        @NonNull
080        public static Builder withRateLimitPartitionKey(@NonNull String rateLimitPartitionKey) {
081                return new Builder(rateLimitPartitionKey);
082        }
083
084        private McpAdmissionIdentity(@NonNull Builder builder) {
085                this.rateLimitPartitionKey = requirePartitionKey(
086                                builder.rateLimitPartitionKey, "rateLimitPartitionKey");
087                this.authorizationPartitionKey = builder.authorizationPartitionKey == null
088                                ? null : requirePartitionKey(builder.authorizationPartitionKey,
089                                "authorizationPartitionKey");
090                this.principal = builder.principal;
091                this.applicationContext = builder.applicationContext;
092                if (this.principal != null && this.authorizationPartitionKey == null)
093                        throw new IllegalStateException(
094                                        "authorizationPartitionKey is required when principal is present");
095        }
096
097        /** @return whether a principal is present */
098        @NonNull
099        public Boolean isAuthenticated() {
100                return this.principal != null;
101        }
102
103        /** @return application principal, when authenticated */
104        @NonNull
105        public Optional<@NonNull Object> getPrincipal() {
106                return Optional.ofNullable(this.principal);
107        }
108
109        /** @return optional application context propagated to later MCP hooks */
110        @NonNull
111        public Optional<@NonNull Object> getApplicationContext() {
112                return Optional.ofNullable(this.applicationContext);
113        }
114
115        /** @return opaque rate-limit partition key */
116        @NonNull
117        public String getRateLimitPartitionKey() {
118                return this.rateLimitPartitionKey;
119        }
120
121        /**
122         * Returns the opaque authorization partition key. Absence selects Soklet's
123         * endpoint-scoped anonymous authorization partition.
124         *
125         * @return authorization partition key, when supplied
126         */
127        @NonNull
128        public Optional<@NonNull String> getAuthorizationPartitionKey() {
129                return Optional.ofNullable(this.authorizationPartitionKey);
130        }
131
132        @NonNull
133        private static String requirePartitionKey(@NonNull String value,
134                        @NonNull String name) {
135                requireNonNull(value, name);
136                if (value.isBlank())
137                        throw new IllegalArgumentException(name + " must not be blank");
138                try {
139                        int length = StandardCharsets.UTF_8.newEncoder()
140                                        .onMalformedInput(CodingErrorAction.REPORT)
141                                        .onUnmappableCharacter(CodingErrorAction.REPORT)
142                                        .encode(CharBuffer.wrap(value)).remaining();
143                        if (length > MAXIMUM_PARTITION_KEY_SIZE_IN_UTF_8_BYTES)
144                                throw new IllegalArgumentException(name + " must contain at most "
145                                                + MAXIMUM_PARTITION_KEY_SIZE_IN_UTF_8_BYTES + " UTF-8 bytes");
146                } catch (CharacterCodingException exception) {
147                        throw new IllegalArgumentException(name + " must contain valid Unicode text", exception);
148                }
149                return value;
150        }
151
152        /**
153         * Mutable builder for an immutable {@link McpAdmissionIdentity}.
154         *
155         * @author <a href="https://www.revetkn.com">Mark Allen</a>
156         */
157        @NotThreadSafe
158        public static final class Builder {
159                @NonNull
160                private final String rateLimitPartitionKey;
161                @Nullable
162                private String authorizationPartitionKey;
163                @Nullable
164                private Object principal;
165                @Nullable
166                private Object applicationContext;
167
168                private Builder(@NonNull String rateLimitPartitionKey) {
169                        this.rateLimitPartitionKey = requireNonNull(rateLimitPartitionKey);
170                }
171
172                /**
173                 * Sets the stable authorization partition key.
174                 *
175                 * @param authorizationPartitionKey opaque authorization partition
176                 * @return this builder
177                 */
178                @NonNull
179                public Builder authorizationPartitionKey(@NonNull String authorizationPartitionKey) {
180                        this.authorizationPartitionKey = requireNonNull(authorizationPartitionKey);
181                        return this;
182                }
183
184                /**
185                 * Sets the authenticated application principal. An authorization partition
186                 * key must also be supplied.
187                 *
188                 * @param principal application principal
189                 * @return this builder
190                 */
191                @NonNull
192                public Builder principal(@NonNull Object principal) {
193                        this.principal = requireNonNull(principal);
194                        return this;
195                }
196
197                /**
198                 * Sets optional application context propagated to later MCP hooks.
199                 *
200                 * @param applicationContext application context
201                 * @return this builder
202                 */
203                @NonNull
204                public Builder applicationContext(@NonNull Object applicationContext) {
205                        this.applicationContext = requireNonNull(applicationContext);
206                        return this;
207                }
208
209                /** @return immutable admission identity */
210                @NonNull
211                public McpAdmissionIdentity build() {
212                        return new McpAdmissionIdentity(this);
213                }
214        }
215}